mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-24 00:34:09 +00:00
merge main into rh-test
This commit is contained in:
148
src/renderer/core/canvas/canvasStore.ts
Normal file
148
src/renderer/core/canvas/canvasStore.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { type Raw, computed, markRaw, ref, shallowRef } from 'vue'
|
||||
|
||||
import type { Point, Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphGroup,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import { isLGraphGroup, isLGraphNode, isReroute } from '@/utils/litegraphUtil'
|
||||
|
||||
export const useTitleEditorStore = defineStore('titleEditor', () => {
|
||||
const titleEditorTarget = shallowRef<LGraphNode | LGraphGroup | null>(null)
|
||||
|
||||
return {
|
||||
titleEditorTarget
|
||||
}
|
||||
})
|
||||
|
||||
export const useCanvasStore = defineStore('canvas', () => {
|
||||
/**
|
||||
* The LGraphCanvas instance.
|
||||
*
|
||||
* The root LGraphCanvas object is a shallow ref.
|
||||
*/
|
||||
const canvas = shallowRef<LGraphCanvas | null>(null)
|
||||
/**
|
||||
* The selected items on the canvas. All stored items are raw.
|
||||
*/
|
||||
const selectedItems = ref<Raw<Positionable>[]>([])
|
||||
const updateSelectedItems = () => {
|
||||
const items = Array.from(canvas.value?.selectedItems ?? [])
|
||||
selectedItems.value = items.map((item) => markRaw(item))
|
||||
}
|
||||
|
||||
// Reactive scale percentage that syncs with app.canvas.ds.scale
|
||||
const appScalePercentage = ref(100)
|
||||
|
||||
// Set up scale synchronization when canvas is available
|
||||
let originalOnChanged: ((scale: number, offset: Point) => void) | undefined =
|
||||
undefined
|
||||
const initScaleSync = () => {
|
||||
if (app.canvas?.ds) {
|
||||
// Initial sync
|
||||
originalOnChanged = app.canvas.ds.onChanged
|
||||
appScalePercentage.value = Math.round(app.canvas.ds.scale * 100)
|
||||
|
||||
// Set up continuous sync
|
||||
app.canvas.ds.onChanged = () => {
|
||||
if (app.canvas?.ds?.scale) {
|
||||
appScalePercentage.value = Math.round(app.canvas.ds.scale * 100)
|
||||
}
|
||||
// Call original handler if exists
|
||||
originalOnChanged?.(app.canvas.ds.scale, app.canvas.ds.offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupScaleSync = () => {
|
||||
if (app.canvas?.ds) {
|
||||
app.canvas.ds.onChanged = originalOnChanged
|
||||
originalOnChanged = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const nodeSelected = computed(() => selectedItems.value.some(isLGraphNode))
|
||||
const groupSelected = computed(() => selectedItems.value.some(isLGraphGroup))
|
||||
const rerouteSelected = computed(() => selectedItems.value.some(isReroute))
|
||||
|
||||
const getCanvas = () => {
|
||||
if (!canvas.value) throw new Error('getCanvas: canvas is null')
|
||||
return canvas.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the canvas zoom level from a percentage value
|
||||
* @param percentage - Zoom percentage value (1-1000, where 1000 = 1000% zoom)
|
||||
*/
|
||||
const setAppZoomFromPercentage = (percentage: number) => {
|
||||
if (!app.canvas?.ds || percentage <= 0) return
|
||||
|
||||
// Convert percentage to scale (1000% = 10.0 scale)
|
||||
const newScale = percentage / 100
|
||||
const ds = app.canvas.ds
|
||||
|
||||
ds.changeScale(
|
||||
newScale,
|
||||
ds.element ? [ds.element.width / 2, ds.element.height / 2] : undefined
|
||||
)
|
||||
app.canvas.setDirty(true, true)
|
||||
|
||||
// Update reactive value immediately for UI consistency
|
||||
appScalePercentage.value = Math.round(newScale * 100)
|
||||
}
|
||||
|
||||
const currentGraph = shallowRef<LGraph | null>(null)
|
||||
const isInSubgraph = ref(false)
|
||||
|
||||
// Provide selection state to all Vue nodes
|
||||
const selectedNodeIds = computed(
|
||||
() =>
|
||||
new Set(
|
||||
selectedItems.value
|
||||
.filter((item) => item.id !== undefined)
|
||||
.map((item) => String(item.id))
|
||||
)
|
||||
)
|
||||
|
||||
whenever(
|
||||
() => canvas.value,
|
||||
(newCanvas) => {
|
||||
useEventListener(
|
||||
newCanvas.canvas,
|
||||
'litegraph:set-graph',
|
||||
(event: CustomEvent<{ newGraph: LGraph; oldGraph: LGraph }>) => {
|
||||
const newGraph = event.detail?.newGraph ?? app.canvas?.graph // TODO: Ambiguous Graph
|
||||
currentGraph.value = newGraph
|
||||
isInSubgraph.value = Boolean(app.canvas?.subgraph)
|
||||
}
|
||||
)
|
||||
|
||||
useEventListener(newCanvas.canvas, 'subgraph-opened', () => {
|
||||
isInSubgraph.value = true
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return {
|
||||
canvas,
|
||||
selectedItems,
|
||||
selectedNodeIds,
|
||||
nodeSelected,
|
||||
groupSelected,
|
||||
rerouteSelected,
|
||||
appScalePercentage,
|
||||
updateSelectedItems,
|
||||
getCanvas,
|
||||
setAppZoomFromPercentage,
|
||||
initScaleSync,
|
||||
cleanupScaleSync,
|
||||
currentGraph,
|
||||
isInSubgraph
|
||||
}
|
||||
})
|
||||
72
src/renderer/core/canvas/links/slotLinkCompatibility.ts
Normal file
72
src/renderer/core/canvas/links/slotLinkCompatibility.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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 }
|
||||
}
|
||||
95
src/renderer/core/canvas/links/slotLinkDragState.ts
Normal file
95
src/renderer/core/canvas/links/slotLinkDragState.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { reactive, readonly } from 'vue'
|
||||
|
||||
import type { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { Point, SlotLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
type SlotDragType = 'input' | 'output'
|
||||
|
||||
export interface SlotDragSource {
|
||||
nodeId: string
|
||||
slotIndex: number
|
||||
type: SlotDragType
|
||||
direction: LinkDirection
|
||||
position: Readonly<Point>
|
||||
}
|
||||
|
||||
export interface SlotDropCandidate {
|
||||
layout: SlotLayout
|
||||
compatible: boolean
|
||||
}
|
||||
|
||||
interface PointerPosition {
|
||||
client: Point
|
||||
canvas: Point
|
||||
}
|
||||
|
||||
interface SlotDragState {
|
||||
active: boolean
|
||||
pointerId: number | null
|
||||
source: SlotDragSource | null
|
||||
pointer: PointerPosition
|
||||
candidate: SlotDropCandidate | null
|
||||
}
|
||||
|
||||
const state = reactive<SlotDragState>({
|
||||
active: false,
|
||||
pointerId: null,
|
||||
source: null,
|
||||
pointer: {
|
||||
client: { x: 0, y: 0 },
|
||||
canvas: { x: 0, y: 0 }
|
||||
},
|
||||
candidate: null
|
||||
})
|
||||
|
||||
function updatePointerPosition(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
canvasX: number,
|
||||
canvasY: number
|
||||
) {
|
||||
state.pointer.client.x = clientX
|
||||
state.pointer.client.y = clientY
|
||||
state.pointer.canvas.x = canvasX
|
||||
state.pointer.canvas.y = canvasY
|
||||
}
|
||||
|
||||
function setCandidate(candidate: SlotDropCandidate | null) {
|
||||
state.candidate = candidate
|
||||
}
|
||||
|
||||
function beginDrag(source: SlotDragSource, pointerId: number) {
|
||||
state.active = true
|
||||
state.source = source
|
||||
state.pointerId = pointerId
|
||||
state.candidate = null
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
state.active = false
|
||||
state.pointerId = null
|
||||
state.source = null
|
||||
state.pointer.client.x = 0
|
||||
state.pointer.client.y = 0
|
||||
state.pointer.canvas.x = 0
|
||||
state.pointer.canvas.y = 0
|
||||
state.candidate = null
|
||||
}
|
||||
|
||||
function getSlotLayout(nodeId: string, slotIndex: number, isInput: boolean) {
|
||||
const slotKey = getSlotKey(nodeId, slotIndex, isInput)
|
||||
return layoutStore.getSlotLayout(slotKey)
|
||||
}
|
||||
|
||||
export function useSlotLinkDragState() {
|
||||
return {
|
||||
state: readonly(state),
|
||||
beginDrag,
|
||||
endDrag,
|
||||
updatePointerPosition,
|
||||
setCandidate,
|
||||
getSlotLayout
|
||||
}
|
||||
}
|
||||
95
src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts
Normal file
95
src/renderer/core/canvas/links/slotLinkPreviewRenderer.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
ReadOnlyPoint
|
||||
} 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 type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
|
||||
function buildContext(canvas: LGraphCanvas): LinkRenderContext {
|
||||
return {
|
||||
renderMode: canvas.links_render_mode,
|
||||
connectionWidth: canvas.connections_width,
|
||||
renderBorder: canvas.render_connections_border,
|
||||
lowQuality: canvas.low_quality,
|
||||
highQualityRender: canvas.highquality_render,
|
||||
scale: canvas.ds.scale,
|
||||
linkMarkerShape: canvas.linkMarkerShape,
|
||||
renderConnectionArrows: canvas.render_connection_arrows,
|
||||
highlightedLinks: new Set(Object.keys(canvas.highlighted_links)),
|
||||
defaultLinkColor: canvas.default_link_color,
|
||||
linkTypeColors: (canvas.constructor as typeof LGraphCanvas)
|
||||
.link_type_colors,
|
||||
disabledPattern: canvas._pattern
|
||||
}
|
||||
}
|
||||
|
||||
export function attachSlotLinkPreviewRenderer(canvas: LGraphCanvas) {
|
||||
const originalOnDrawForeground = canvas.onDrawForeground?.bind(canvas)
|
||||
const patched = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
area: LGraphCanvas['visible_area']
|
||||
) => {
|
||||
originalOnDrawForeground?.(ctx, area)
|
||||
|
||||
const { state } = useSlotLinkDragState()
|
||||
if (!state.active || !state.source) return
|
||||
|
||||
const { pointer, source } = state
|
||||
const start = source.position
|
||||
const sourceSlot = resolveSourceSlot(canvas, source)
|
||||
|
||||
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)
|
||||
|
||||
ctx.save()
|
||||
|
||||
linkRenderer.renderDraggingLink(
|
||||
ctx,
|
||||
from,
|
||||
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
|
||||
|
||||
const nodeId = Number(source.nodeId)
|
||||
if (!Number.isFinite(nodeId)) return undefined
|
||||
|
||||
const node = graph.getNodeById(nodeId)
|
||||
if (!node) return undefined
|
||||
|
||||
return source.type === 'output'
|
||||
? node.outputs?.[source.slotIndex]
|
||||
: node.inputs?.[source.slotIndex]
|
||||
}
|
||||
429
src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts
Normal file
429
src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* Litegraph Link Adapter
|
||||
*
|
||||
* Bridges the gap between litegraph's data model and the pure canvas renderer.
|
||||
* Converts litegraph-specific types (LLink, LGraphNode, slots) into generic
|
||||
* rendering data that can be consumed by the PathRenderer.
|
||||
* Maintains backward compatibility with existing litegraph integration.
|
||||
*/
|
||||
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 { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LinkDirection,
|
||||
LinkMarkerShape,
|
||||
LinkRenderType
|
||||
} from '@/lib/litegraph/src/types/globalEnums'
|
||||
import {
|
||||
type ArrowShape,
|
||||
CanvasPathRenderer,
|
||||
type Direction,
|
||||
type LinkRenderData,
|
||||
type RenderContext as PathRenderContext,
|
||||
type Point,
|
||||
type RenderMode
|
||||
} from '@/renderer/core/canvas/pathRenderer'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
export interface LinkRenderContext {
|
||||
// Canvas settings
|
||||
renderMode: LinkRenderType
|
||||
connectionWidth: number
|
||||
renderBorder: boolean
|
||||
lowQuality: boolean
|
||||
highQualityRender: boolean
|
||||
scale: number
|
||||
linkMarkerShape: LinkMarkerShape
|
||||
renderConnectionArrows: boolean
|
||||
|
||||
// State
|
||||
highlightedLinks: Set<string | number>
|
||||
|
||||
// Colors
|
||||
defaultLinkColor: CanvasColour
|
||||
linkTypeColors: Record<string, CanvasColour>
|
||||
|
||||
// Pattern for disabled links (optional)
|
||||
disabledPattern?: CanvasPattern | null
|
||||
}
|
||||
|
||||
export class LitegraphLinkAdapter {
|
||||
private readonly pathRenderer = new CanvasPathRenderer()
|
||||
|
||||
constructor(public readonly enableLayoutStoreWrites = true) {}
|
||||
|
||||
/**
|
||||
* Convert LinkDirection enum to Direction string
|
||||
*/
|
||||
private convertDirection(dir: LinkDirection): Direction {
|
||||
switch (dir) {
|
||||
case LinkDirection.LEFT:
|
||||
return 'left'
|
||||
case LinkDirection.RIGHT:
|
||||
return 'right'
|
||||
case LinkDirection.UP:
|
||||
return 'up'
|
||||
case LinkDirection.DOWN:
|
||||
return 'down'
|
||||
case LinkDirection.CENTER:
|
||||
return 'none'
|
||||
default:
|
||||
return 'right'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert LinkRenderContext to PathRenderContext
|
||||
*/
|
||||
private convertToPathRenderContext(
|
||||
context: LinkRenderContext
|
||||
): PathRenderContext {
|
||||
// Match original arrow rendering conditions:
|
||||
// Arrows only render when scale >= 0.6 AND highquality_render AND render_connection_arrows
|
||||
const shouldShowArrows =
|
||||
context.scale >= 0.6 &&
|
||||
context.highQualityRender &&
|
||||
context.renderConnectionArrows
|
||||
|
||||
// Only show center marker when not set to None
|
||||
const shouldShowCenterMarker =
|
||||
context.linkMarkerShape !== LinkMarkerShape.None
|
||||
|
||||
return {
|
||||
style: {
|
||||
mode: this.convertRenderMode(context.renderMode),
|
||||
connectionWidth: context.connectionWidth,
|
||||
borderWidth: context.renderBorder ? 4 : undefined,
|
||||
arrowShape: this.convertArrowShape(context.linkMarkerShape),
|
||||
showArrows: shouldShowArrows,
|
||||
lowQuality: context.lowQuality,
|
||||
// Center marker settings (matches original litegraph behavior)
|
||||
showCenterMarker: shouldShowCenterMarker,
|
||||
centerMarkerShape:
|
||||
context.linkMarkerShape === LinkMarkerShape.Arrow
|
||||
? 'arrow'
|
||||
: 'circle',
|
||||
highQuality: context.highQualityRender
|
||||
},
|
||||
colors: {
|
||||
default: String(context.defaultLinkColor),
|
||||
byType: this.convertColorMap(context.linkTypeColors),
|
||||
highlighted: '#FFF'
|
||||
},
|
||||
patterns: {
|
||||
disabled: context.disabledPattern
|
||||
},
|
||||
animation: {
|
||||
time: LiteGraph.getTime() * 0.001
|
||||
},
|
||||
scale: context.scale,
|
||||
highlightedIds: new Set(Array.from(context.highlightedLinks).map(String))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert LinkRenderType to RenderMode
|
||||
*/
|
||||
private convertRenderMode(mode: LinkRenderType): RenderMode {
|
||||
switch (mode) {
|
||||
case LinkRenderType.LINEAR_LINK:
|
||||
return 'linear'
|
||||
case LinkRenderType.STRAIGHT_LINK:
|
||||
return 'straight'
|
||||
case LinkRenderType.SPLINE_LINK:
|
||||
default:
|
||||
return 'spline'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert LinkMarkerShape to ArrowShape
|
||||
*/
|
||||
private convertArrowShape(shape: LinkMarkerShape): ArrowShape {
|
||||
switch (shape) {
|
||||
case LinkMarkerShape.Circle:
|
||||
return 'circle'
|
||||
case LinkMarkerShape.Arrow:
|
||||
default:
|
||||
return 'triangle'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert color map to ensure all values are strings
|
||||
*/
|
||||
private convertColorMap(
|
||||
colors: Record<string, CanvasColour>
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(colors)) {
|
||||
result[key] = String(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply spline offset to a point, mimicking original #addSplineOffset behavior
|
||||
* Critically: does nothing for CENTER/NONE directions (no case for them)
|
||||
*/
|
||||
private applySplineOffset(
|
||||
point: Point,
|
||||
direction: LinkDirection,
|
||||
distance: number
|
||||
): void {
|
||||
switch (direction) {
|
||||
case LinkDirection.LEFT:
|
||||
point.x -= distance
|
||||
break
|
||||
case LinkDirection.RIGHT:
|
||||
point.x += distance
|
||||
break
|
||||
case LinkDirection.UP:
|
||||
point.y -= distance
|
||||
break
|
||||
case LinkDirection.DOWN:
|
||||
point.y += distance
|
||||
break
|
||||
// CENTER and NONE: no offset applied (original behavior)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct rendering method compatible with LGraphCanvas
|
||||
* Converts data and delegates to pure renderer
|
||||
*/
|
||||
renderLinkDirect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
a: ReadOnlyPoint,
|
||||
b: ReadOnlyPoint,
|
||||
link: LLink | null,
|
||||
skip_border: boolean,
|
||||
flow: number | boolean | null,
|
||||
color: CanvasColour | null,
|
||||
start_dir: LinkDirection,
|
||||
end_dir: LinkDirection,
|
||||
context: LinkRenderContext,
|
||||
extras: {
|
||||
reroute?: Reroute
|
||||
startControl?: ReadOnlyPoint
|
||||
endControl?: ReadOnlyPoint
|
||||
num_sublines?: number
|
||||
disabled?: boolean
|
||||
} = {}
|
||||
): void {
|
||||
// Apply same defaults as original renderLink
|
||||
const startDir = start_dir || LinkDirection.RIGHT
|
||||
const endDir = end_dir || LinkDirection.LEFT
|
||||
|
||||
// Convert flow to boolean
|
||||
const flowBool = flow === true || (typeof flow === 'number' && flow > 0)
|
||||
|
||||
// Create LinkRenderData from direct parameters
|
||||
const linkData: LinkRenderData = {
|
||||
id: link ? String(link.id) : 'temp',
|
||||
startPoint: { x: a[0], y: a[1] },
|
||||
endPoint: { x: b[0], y: b[1] },
|
||||
startDirection: this.convertDirection(startDir),
|
||||
endDirection: this.convertDirection(endDir),
|
||||
color: color !== null && color !== undefined ? String(color) : undefined,
|
||||
type: link?.type !== undefined ? String(link.type) : undefined,
|
||||
flow: flowBool,
|
||||
disabled: extras.disabled || false
|
||||
}
|
||||
|
||||
// Control points handling (spline mode):
|
||||
// - Pre-refactor, the old renderLink honored a single provided control and
|
||||
// derived the missing side via #addSplineOffset (CENTER => no offset).
|
||||
// - Restore that behavior here so reroute segments render identically.
|
||||
if (context.renderMode === LinkRenderType.SPLINE_LINK) {
|
||||
const hasStartCtrl = !!extras.startControl
|
||||
const hasEndCtrl = !!extras.endControl
|
||||
|
||||
// Compute distance once for offsets
|
||||
const dist = Math.sqrt(
|
||||
(b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1])
|
||||
)
|
||||
const factor = 0.25
|
||||
|
||||
const cps: Point[] = []
|
||||
|
||||
if (hasStartCtrl && hasEndCtrl) {
|
||||
// Both provided explicitly
|
||||
cps.push(
|
||||
{
|
||||
x: a[0] + (extras.startControl![0] || 0),
|
||||
y: a[1] + (extras.startControl![1] || 0)
|
||||
},
|
||||
{
|
||||
x: b[0] + (extras.endControl![0] || 0),
|
||||
y: b[1] + (extras.endControl![1] || 0)
|
||||
}
|
||||
)
|
||||
linkData.controlPoints = cps
|
||||
} else if (hasStartCtrl && !hasEndCtrl) {
|
||||
// Start provided, derive end via direction offset (CENTER => no offset)
|
||||
const start = {
|
||||
x: a[0] + (extras.startControl![0] || 0),
|
||||
y: a[1] + (extras.startControl![1] || 0)
|
||||
}
|
||||
const end = { x: b[0], y: b[1] }
|
||||
this.applySplineOffset(end, endDir, dist * factor)
|
||||
cps.push(start, end)
|
||||
linkData.controlPoints = cps
|
||||
} else if (!hasStartCtrl && hasEndCtrl) {
|
||||
// End provided, derive start via direction offset (CENTER => no offset)
|
||||
const start = { x: a[0], y: a[1] }
|
||||
this.applySplineOffset(start, startDir, dist * factor)
|
||||
const end = {
|
||||
x: b[0] + (extras.endControl![0] || 0),
|
||||
y: b[1] + (extras.endControl![1] || 0)
|
||||
}
|
||||
cps.push(start, end)
|
||||
linkData.controlPoints = cps
|
||||
} else {
|
||||
// Neither provided: derive both from directions (CENTER => no offset)
|
||||
const start = { x: a[0], y: a[1] }
|
||||
const end = { x: b[0], y: b[1] }
|
||||
this.applySplineOffset(start, startDir, dist * factor)
|
||||
this.applySplineOffset(end, endDir, dist * factor)
|
||||
cps.push(start, end)
|
||||
linkData.controlPoints = cps
|
||||
}
|
||||
}
|
||||
|
||||
// Convert context
|
||||
const pathContext = this.convertToPathRenderContext(context)
|
||||
|
||||
// Override skip_border if needed
|
||||
if (skip_border) {
|
||||
pathContext.style.borderWidth = undefined
|
||||
}
|
||||
|
||||
// Render using pure renderer
|
||||
const path = this.pathRenderer.drawLink(ctx, linkData, pathContext)
|
||||
|
||||
// Store path for hit detection
|
||||
const linkSegment = extras.reroute ?? link
|
||||
if (linkSegment) {
|
||||
linkSegment.path = path
|
||||
|
||||
// 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[0] = linkData.centerPos.x
|
||||
linkSegment._pos[1] = linkData.centerPos.y
|
||||
|
||||
// Store center angle if calculated (for arrow markers)
|
||||
if (linkData.centerAngle !== undefined) {
|
||||
linkSegment._centreAngle = linkData.centerAngle
|
||||
}
|
||||
}
|
||||
|
||||
// Update layout store when writes are enabled (event-driven path)
|
||||
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
|
||||
)
|
||||
const centerPos = linkData.centerPos || {
|
||||
x: (linkData.startPoint.x + linkData.endPoint.x) / 2,
|
||||
y: (linkData.startPoint.y + linkData.endPoint.y) / 2
|
||||
}
|
||||
|
||||
// Update whole link layout (only if not a reroute segment)
|
||||
if (!extras.reroute) {
|
||||
layoutStore.updateLinkLayout(link.id, {
|
||||
id: link.id,
|
||||
path: path,
|
||||
bounds: bounds,
|
||||
centerPos: centerPos,
|
||||
sourceNodeId: String(link.origin_id),
|
||||
targetNodeId: String(link.target_id),
|
||||
sourceSlot: link.origin_slot,
|
||||
targetSlot: link.target_slot
|
||||
})
|
||||
}
|
||||
|
||||
// Always update segment layout (for both regular links and reroute segments)
|
||||
const rerouteId = extras.reroute ? extras.reroute.id : null
|
||||
layoutStore.updateLinkSegmentLayout(link.id, rerouteId, {
|
||||
path: path,
|
||||
bounds: bounds,
|
||||
centerPos: centerPos
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderDraggingLink(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
from: ReadOnlyPoint,
|
||||
to: ReadOnlyPoint,
|
||||
colour: CanvasColour,
|
||||
startDir: LinkDirection,
|
||||
endDir: LinkDirection,
|
||||
context: LinkRenderContext
|
||||
): void {
|
||||
this.renderLinkDirect(
|
||||
ctx,
|
||||
from,
|
||||
to,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
colour,
|
||||
startDir,
|
||||
endDir,
|
||||
{
|
||||
...context,
|
||||
linkMarkerShape: LinkMarkerShape.None
|
||||
},
|
||||
{
|
||||
disabled: false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bounding box for a link
|
||||
* Includes padding for line width and control points
|
||||
*/
|
||||
private calculateLinkBounds(
|
||||
startPos: ReadOnlyPoint,
|
||||
endPos: ReadOnlyPoint,
|
||||
linkData: LinkRenderData
|
||||
): Bounds {
|
||||
let minX = Math.min(startPos[0], endPos[0])
|
||||
let maxX = Math.max(startPos[0], endPos[0])
|
||||
let minY = Math.min(startPos[1], endPos[1])
|
||||
let maxY = Math.max(startPos[1], endPos[1])
|
||||
|
||||
// Include control points if they exist (for spline links)
|
||||
if (linkData.controlPoints) {
|
||||
for (const cp of linkData.controlPoints) {
|
||||
minX = Math.min(minX, cp.x)
|
||||
maxX = Math.max(maxX, cp.x)
|
||||
minY = Math.min(minY, cp.y)
|
||||
maxY = Math.max(maxY, cp.y)
|
||||
}
|
||||
}
|
||||
|
||||
// Add padding for line width and hit tolerance
|
||||
const padding = 20
|
||||
|
||||
return {
|
||||
x: minX - padding,
|
||||
y: minY - padding,
|
||||
width: maxX - minX + 2 * padding,
|
||||
height: maxY - minY + 2 * padding
|
||||
}
|
||||
}
|
||||
}
|
||||
209
src/renderer/core/canvas/litegraph/slotCalculations.ts
Normal file
209
src/renderer/core/canvas/litegraph/slotCalculations.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Slot Position Calculations
|
||||
*
|
||||
* Centralized utility for calculating input/output slot positions on nodes.
|
||||
* This allows both litegraph nodes and the layout system to use the same
|
||||
* calculation logic while providing their own position data.
|
||||
*/
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
Point
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { isWidgetInputSlot } from '@/lib/litegraph/src/node/slotUtils'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
export interface SlotPositionContext {
|
||||
/** Node's X position in graph coordinates */
|
||||
nodeX: number
|
||||
/** Node's Y position in graph coordinates */
|
||||
nodeY: number
|
||||
/** Node's width */
|
||||
nodeWidth: number
|
||||
/** Node's height */
|
||||
nodeHeight: number
|
||||
/** Whether the node is collapsed */
|
||||
collapsed: boolean
|
||||
/** Collapsed width (if applicable) */
|
||||
collapsedWidth?: number
|
||||
/** Node constructor's slot_start_y offset */
|
||||
slotStartY?: number
|
||||
/** Node's input slots */
|
||||
inputs: INodeInputSlot[]
|
||||
/** Node's output slots */
|
||||
outputs: INodeOutputSlot[]
|
||||
/** Node's widgets (for widget slot detection) */
|
||||
widgets?: Array<{ name?: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the position of an input slot in graph coordinates
|
||||
* @param context Node context containing position and slot data
|
||||
* @param slot The input slot index
|
||||
* @returns Position of the input slot center in graph coordinates
|
||||
*/
|
||||
export function calculateInputSlotPos(
|
||||
context: SlotPositionContext,
|
||||
slot: number
|
||||
): Point {
|
||||
const input = context.inputs[slot]
|
||||
if (!input) return [context.nodeX, context.nodeY]
|
||||
|
||||
return calculateInputSlotPosFromSlot(context, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the position of an input slot in graph coordinates
|
||||
* @param context Node context containing position and slot data
|
||||
* @param input The input slot object
|
||||
* @returns Position of the input slot center in graph coordinates
|
||||
*/
|
||||
export function calculateInputSlotPosFromSlot(
|
||||
context: SlotPositionContext,
|
||||
input: INodeInputSlot
|
||||
): Point {
|
||||
const { nodeX, nodeY, collapsed } = context
|
||||
|
||||
// Handle collapsed nodes
|
||||
if (collapsed) {
|
||||
const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5
|
||||
return [nodeX, nodeY - halfTitle]
|
||||
}
|
||||
|
||||
// Handle hard-coded positions
|
||||
const { pos } = input
|
||||
if (pos) return [nodeX + pos[0], nodeY + pos[1]]
|
||||
|
||||
// Default vertical slots
|
||||
const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
const nodeOffsetY = context.slotStartY || 0
|
||||
const defaultVerticalInputs = getDefaultVerticalInputs(context)
|
||||
const slotIndex = defaultVerticalInputs.indexOf(input)
|
||||
const slotY = (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT
|
||||
|
||||
return [nodeX + offsetX, nodeY + slotY + nodeOffsetY]
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the position of an output slot in graph coordinates
|
||||
* @param context Node context containing position and slot data
|
||||
* @param slot The output slot index
|
||||
* @returns Position of the output slot center in graph coordinates
|
||||
*/
|
||||
export function calculateOutputSlotPos(
|
||||
context: SlotPositionContext,
|
||||
slot: number
|
||||
): Point {
|
||||
const { nodeX, nodeY, nodeWidth, collapsed, collapsedWidth, outputs } =
|
||||
context
|
||||
|
||||
// Handle collapsed nodes
|
||||
if (collapsed) {
|
||||
const width = collapsedWidth || LiteGraph.NODE_COLLAPSED_WIDTH
|
||||
const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5
|
||||
return [nodeX + width, nodeY - halfTitle]
|
||||
}
|
||||
|
||||
const outputSlot = outputs[slot]
|
||||
if (!outputSlot) return [nodeX + nodeWidth, nodeY]
|
||||
|
||||
// Handle hard-coded positions
|
||||
const outputPos = outputSlot.pos
|
||||
if (outputPos) return [nodeX + outputPos[0], nodeY + outputPos[1]]
|
||||
|
||||
// Default vertical slots
|
||||
const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
const nodeOffsetY = context.slotStartY || 0
|
||||
const defaultVerticalOutputs = getDefaultVerticalOutputs(context)
|
||||
const slotIndex = defaultVerticalOutputs.indexOf(outputSlot)
|
||||
const slotY = (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT
|
||||
|
||||
// TODO: Why +1?
|
||||
return [nodeX + nodeWidth + 1 - offsetX, nodeY + slotY + nodeOffsetY]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get slot position using layout tree if available, fallback to node's position
|
||||
* Unified implementation used by both LitegraphLinkAdapter and useLinkLayoutSync
|
||||
* @param node The LGraphNode
|
||||
* @param slotIndex The slot index
|
||||
* @param isInput Whether this is an input slot
|
||||
* @returns Position of the slot center in graph coordinates
|
||||
*/
|
||||
export function getSlotPosition(
|
||||
node: LGraphNode,
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
): Point {
|
||||
// Try to get precise position from slot layout (DOM-registered)
|
||||
const slotKey = getSlotKey(String(node.id), slotIndex, isInput)
|
||||
const slotLayout = layoutStore.getSlotLayout(slotKey)
|
||||
if (slotLayout) {
|
||||
return [slotLayout.position.x, slotLayout.position.y]
|
||||
}
|
||||
|
||||
// Fallback: derive position from node layout tree and slot model
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(String(node.id)).value
|
||||
|
||||
if (nodeLayout) {
|
||||
// Create context from layout tree data
|
||||
const context: SlotPositionContext = {
|
||||
nodeX: nodeLayout.position.x,
|
||||
nodeY: nodeLayout.position.y,
|
||||
nodeWidth: nodeLayout.size.width,
|
||||
nodeHeight: nodeLayout.size.height,
|
||||
collapsed: node.flags.collapsed || false,
|
||||
collapsedWidth: node._collapsed_width,
|
||||
slotStartY: node.constructor.slot_start_y,
|
||||
inputs: node.inputs,
|
||||
outputs: node.outputs,
|
||||
widgets: node.widgets
|
||||
}
|
||||
|
||||
// Use helper to calculate position
|
||||
return isInput
|
||||
? calculateInputSlotPos(context, slotIndex)
|
||||
: calculateOutputSlotPos(context, slotIndex)
|
||||
}
|
||||
|
||||
// Fallback: calculate directly from node properties if layout not available
|
||||
const context: SlotPositionContext = {
|
||||
nodeX: node.pos[0],
|
||||
nodeY: node.pos[1],
|
||||
nodeWidth: node.size[0],
|
||||
nodeHeight: node.size[1],
|
||||
collapsed: node.flags.collapsed || false,
|
||||
collapsedWidth: node._collapsed_width,
|
||||
slotStartY: node.constructor.slot_start_y,
|
||||
inputs: node.inputs,
|
||||
outputs: node.outputs,
|
||||
widgets: node.widgets
|
||||
}
|
||||
|
||||
return isInput
|
||||
? calculateInputSlotPos(context, slotIndex)
|
||||
: calculateOutputSlotPos(context, slotIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the inputs that are not positioned with absolute coordinates
|
||||
*/
|
||||
function getDefaultVerticalInputs(
|
||||
context: SlotPositionContext
|
||||
): INodeInputSlot[] {
|
||||
return context.inputs.filter(
|
||||
(slot) => !slot.pos && !(context.widgets?.length && isWidgetInputSlot(slot))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the outputs that are not positioned with absolute coordinates
|
||||
*/
|
||||
function getDefaultVerticalOutputs(
|
||||
context: SlotPositionContext
|
||||
): INodeOutputSlot[] {
|
||||
return context.outputs.filter((slot) => !slot.pos)
|
||||
}
|
||||
838
src/renderer/core/canvas/pathRenderer.ts
Normal file
838
src/renderer/core/canvas/pathRenderer.ts
Normal file
@@ -0,0 +1,838 @@
|
||||
/**
|
||||
* Path Renderer
|
||||
*
|
||||
* Pure canvas2D rendering utility with no framework dependencies.
|
||||
* Renders bezier curves, straight lines, and linear connections between points.
|
||||
* Supports arrows, flow animations, and returns Path2D objects for hit detection.
|
||||
* Can be reused in any canvas-based project without modification.
|
||||
*/
|
||||
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type Direction = 'left' | 'right' | 'up' | 'down' | 'none'
|
||||
export type RenderMode = 'spline' | 'straight' | 'linear'
|
||||
export type ArrowShape = 'triangle' | 'circle' | 'square'
|
||||
|
||||
export interface LinkRenderData {
|
||||
id: string
|
||||
startPoint: Point
|
||||
endPoint: Point
|
||||
startDirection: Direction
|
||||
endDirection: Direction
|
||||
color?: string
|
||||
type?: string
|
||||
controlPoints?: Point[]
|
||||
flow?: boolean
|
||||
disabled?: boolean
|
||||
// Optional multi-segment support
|
||||
segments?: Array<{
|
||||
start: Point
|
||||
end: Point
|
||||
controlPoints?: Point[]
|
||||
}>
|
||||
// Center point storage (for hit detection and menu)
|
||||
centerPos?: Point
|
||||
centerAngle?: number
|
||||
}
|
||||
|
||||
interface RenderStyle {
|
||||
mode: RenderMode
|
||||
connectionWidth: number
|
||||
borderWidth?: number
|
||||
arrowShape?: ArrowShape
|
||||
showArrows?: boolean
|
||||
lowQuality?: boolean
|
||||
// Center marker properties
|
||||
showCenterMarker?: boolean
|
||||
centerMarkerShape?: 'circle' | 'arrow'
|
||||
highQuality?: boolean
|
||||
}
|
||||
|
||||
interface RenderColors {
|
||||
default: string
|
||||
byType: Record<string, string>
|
||||
highlighted: string
|
||||
}
|
||||
|
||||
export interface RenderContext {
|
||||
style: RenderStyle
|
||||
colors: RenderColors
|
||||
patterns?: {
|
||||
disabled?: CanvasPattern | null
|
||||
}
|
||||
animation?: {
|
||||
time: number // Seconds for flow animation
|
||||
}
|
||||
scale?: number // Canvas scale for quality adjustments
|
||||
highlightedIds?: Set<string>
|
||||
}
|
||||
|
||||
interface DragLinkData {
|
||||
/** Fixed end - the slot being dragged from */
|
||||
fixedPoint: Point
|
||||
fixedDirection: Direction
|
||||
/** Moving end - follows mouse */
|
||||
dragPoint: Point
|
||||
dragDirection?: Direction
|
||||
/** Visual properties */
|
||||
color?: string
|
||||
type?: string
|
||||
disabled?: boolean
|
||||
/** Whether dragging from input (reverse direction) */
|
||||
fromInput?: boolean
|
||||
}
|
||||
|
||||
export class CanvasPathRenderer {
|
||||
/**
|
||||
* Draw a link between two points
|
||||
* Returns a Path2D object for hit detection
|
||||
*/
|
||||
drawLink(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
link: LinkRenderData,
|
||||
context: RenderContext
|
||||
): Path2D {
|
||||
const path = new Path2D()
|
||||
|
||||
// Determine final color
|
||||
const isHighlighted = context.highlightedIds?.has(link.id) ?? false
|
||||
const color = this.determineLinkColor(link, context, isHighlighted)
|
||||
|
||||
// Save context state
|
||||
ctx.save()
|
||||
|
||||
// Apply disabled pattern if needed
|
||||
if (link.disabled && context.patterns?.disabled) {
|
||||
ctx.strokeStyle = context.patterns.disabled
|
||||
} else {
|
||||
ctx.strokeStyle = color
|
||||
}
|
||||
|
||||
// Set line properties
|
||||
ctx.lineWidth = context.style.connectionWidth
|
||||
ctx.lineJoin = 'round'
|
||||
|
||||
// Draw border if needed
|
||||
if (context.style.borderWidth && !context.style.lowQuality) {
|
||||
this.drawLinkPath(
|
||||
ctx,
|
||||
path,
|
||||
link,
|
||||
context,
|
||||
context.style.connectionWidth + context.style.borderWidth,
|
||||
'rgba(0,0,0,0.5)'
|
||||
)
|
||||
}
|
||||
|
||||
// Draw main link
|
||||
this.drawLinkPath(
|
||||
ctx,
|
||||
path,
|
||||
link,
|
||||
context,
|
||||
context.style.connectionWidth,
|
||||
color
|
||||
)
|
||||
|
||||
// Calculate and store center position
|
||||
this.calculateCenterPoint(link, context)
|
||||
|
||||
// Draw arrows if needed
|
||||
if (context.style.showArrows) {
|
||||
this.drawArrows(ctx, link, context, color)
|
||||
}
|
||||
|
||||
// Draw center marker if needed (for link menu interaction)
|
||||
if (
|
||||
context.style.showCenterMarker &&
|
||||
context.scale &&
|
||||
context.scale >= 0.6 &&
|
||||
context.style.highQuality
|
||||
) {
|
||||
this.drawCenterMarker(ctx, link, context, color)
|
||||
}
|
||||
|
||||
// Draw flow animation if needed
|
||||
if (link.flow && context.animation) {
|
||||
this.drawFlowAnimation(ctx, path, link, context)
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
private determineLinkColor(
|
||||
link: LinkRenderData,
|
||||
context: RenderContext,
|
||||
isHighlighted: boolean
|
||||
): string {
|
||||
if (isHighlighted) {
|
||||
return context.colors.highlighted
|
||||
}
|
||||
if (link.color) {
|
||||
return link.color
|
||||
}
|
||||
if (link.type && context.colors.byType[link.type]) {
|
||||
return context.colors.byType[link.type]
|
||||
}
|
||||
return context.colors.default
|
||||
}
|
||||
|
||||
private drawLinkPath(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
path: Path2D,
|
||||
link: LinkRenderData,
|
||||
context: RenderContext,
|
||||
lineWidth: number,
|
||||
color: string
|
||||
): void {
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = lineWidth
|
||||
|
||||
const start = link.startPoint
|
||||
const end = link.endPoint
|
||||
|
||||
// Build the path based on render mode
|
||||
if (context.style.mode === 'linear') {
|
||||
this.buildLinearPath(
|
||||
path,
|
||||
start,
|
||||
end,
|
||||
link.startDirection,
|
||||
link.endDirection
|
||||
)
|
||||
} else if (context.style.mode === 'straight') {
|
||||
this.buildStraightPath(
|
||||
path,
|
||||
start,
|
||||
end,
|
||||
link.startDirection,
|
||||
link.endDirection
|
||||
)
|
||||
} else {
|
||||
// Spline mode (default)
|
||||
this.buildSplinePath(
|
||||
path,
|
||||
start,
|
||||
end,
|
||||
link.startDirection,
|
||||
link.endDirection,
|
||||
link.controlPoints
|
||||
)
|
||||
}
|
||||
|
||||
ctx.stroke(path)
|
||||
}
|
||||
|
||||
private buildLinearPath(
|
||||
path: Path2D,
|
||||
start: Point,
|
||||
end: Point,
|
||||
startDir: Direction,
|
||||
endDir: Direction
|
||||
): void {
|
||||
// Match original litegraph LINEAR_LINK mode with 4-point path
|
||||
const l = 15 // offset distance for control points
|
||||
|
||||
const innerA = { x: start.x, y: start.y }
|
||||
const innerB = { x: end.x, y: end.y }
|
||||
|
||||
// Apply directional offsets to create control points
|
||||
switch (startDir) {
|
||||
case 'left':
|
||||
innerA.x -= l
|
||||
break
|
||||
case 'right':
|
||||
innerA.x += l
|
||||
break
|
||||
case 'up':
|
||||
innerA.y -= l
|
||||
break
|
||||
case 'down':
|
||||
innerA.y += l
|
||||
break
|
||||
case 'none':
|
||||
break
|
||||
}
|
||||
|
||||
switch (endDir) {
|
||||
case 'left':
|
||||
innerB.x -= l
|
||||
break
|
||||
case 'right':
|
||||
innerB.x += l
|
||||
break
|
||||
case 'up':
|
||||
innerB.y -= l
|
||||
break
|
||||
case 'down':
|
||||
innerB.y += l
|
||||
break
|
||||
case 'none':
|
||||
break
|
||||
}
|
||||
|
||||
// Draw 4-point path: start -> innerA -> innerB -> end
|
||||
path.moveTo(start.x, start.y)
|
||||
path.lineTo(innerA.x, innerA.y)
|
||||
path.lineTo(innerB.x, innerB.y)
|
||||
path.lineTo(end.x, end.y)
|
||||
}
|
||||
|
||||
private buildStraightPath(
|
||||
path: Path2D,
|
||||
start: Point,
|
||||
end: Point,
|
||||
startDir: Direction,
|
||||
endDir: Direction
|
||||
): void {
|
||||
// Match original STRAIGHT_LINK implementation with l=10 offset
|
||||
const l = 10 // offset distance matching original
|
||||
|
||||
const innerA = { x: start.x, y: start.y }
|
||||
const innerB = { x: end.x, y: end.y }
|
||||
|
||||
// Apply directional offsets to match original behavior
|
||||
switch (startDir) {
|
||||
case 'left':
|
||||
innerA.x -= l
|
||||
break
|
||||
case 'right':
|
||||
innerA.x += l
|
||||
break
|
||||
case 'up':
|
||||
innerA.y -= l
|
||||
break
|
||||
case 'down':
|
||||
innerA.y += l
|
||||
break
|
||||
case 'none':
|
||||
break
|
||||
}
|
||||
|
||||
switch (endDir) {
|
||||
case 'left':
|
||||
innerB.x -= l
|
||||
break
|
||||
case 'right':
|
||||
innerB.x += l
|
||||
break
|
||||
case 'up':
|
||||
innerB.y -= l
|
||||
break
|
||||
case 'down':
|
||||
innerB.y += l
|
||||
break
|
||||
case 'none':
|
||||
break
|
||||
}
|
||||
|
||||
// Calculate midpoint using innerA/innerB positions (matching original)
|
||||
const midX = (innerA.x + innerB.x) * 0.5
|
||||
|
||||
// Build path: start -> innerA -> (midX, innerA.y) -> (midX, innerB.y) -> innerB -> end
|
||||
path.moveTo(start.x, start.y)
|
||||
path.lineTo(innerA.x, innerA.y)
|
||||
path.lineTo(midX, innerA.y)
|
||||
path.lineTo(midX, innerB.y)
|
||||
path.lineTo(innerB.x, innerB.y)
|
||||
path.lineTo(end.x, end.y)
|
||||
}
|
||||
|
||||
private buildSplinePath(
|
||||
path: Path2D,
|
||||
start: Point,
|
||||
end: Point,
|
||||
startDir: Direction,
|
||||
endDir: Direction,
|
||||
controlPoints?: Point[]
|
||||
): void {
|
||||
path.moveTo(start.x, start.y)
|
||||
|
||||
// Calculate control points if not provided
|
||||
const controls =
|
||||
controlPoints || this.calculateControlPoints(start, end, startDir, endDir)
|
||||
|
||||
if (controls.length >= 2) {
|
||||
// Cubic bezier
|
||||
path.bezierCurveTo(
|
||||
controls[0].x,
|
||||
controls[0].y,
|
||||
controls[1].x,
|
||||
controls[1].y,
|
||||
end.x,
|
||||
end.y
|
||||
)
|
||||
} else if (controls.length === 1) {
|
||||
// Quadratic bezier
|
||||
path.quadraticCurveTo(controls[0].x, controls[0].y, end.x, end.y)
|
||||
} else {
|
||||
// Fallback to linear
|
||||
path.lineTo(end.x, end.y)
|
||||
}
|
||||
}
|
||||
|
||||
private calculateControlPoints(
|
||||
start: Point,
|
||||
end: Point,
|
||||
startDir: Direction,
|
||||
endDir: Direction
|
||||
): Point[] {
|
||||
const dist = Math.sqrt(
|
||||
Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2)
|
||||
)
|
||||
const controlDist = Math.max(30, dist * 0.25)
|
||||
|
||||
// Calculate control point offsets based on direction
|
||||
const startControl = this.getDirectionOffset(startDir, controlDist)
|
||||
const endControl = this.getDirectionOffset(endDir, controlDist)
|
||||
|
||||
return [
|
||||
{ x: start.x + startControl.x, y: start.y + startControl.y },
|
||||
{ x: end.x + endControl.x, y: end.y + endControl.y }
|
||||
]
|
||||
}
|
||||
|
||||
private getDirectionOffset(direction: Direction, distance: number): Point {
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
return { x: -distance, y: 0 }
|
||||
case 'right':
|
||||
return { x: distance, y: 0 }
|
||||
case 'up':
|
||||
return { x: 0, y: -distance }
|
||||
case 'down':
|
||||
return { x: 0, y: distance }
|
||||
case 'none':
|
||||
default:
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private drawArrows(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
link: LinkRenderData,
|
||||
context: RenderContext,
|
||||
color: string
|
||||
): void {
|
||||
if (!context.style.showArrows) return
|
||||
|
||||
// Render arrows at 0.25 and 0.75 positions along the path (matching original)
|
||||
const positions = [0.25, 0.75]
|
||||
|
||||
for (const t of positions) {
|
||||
// Compute arrow position and angle
|
||||
const posA = this.computeConnectionPoint(link, t, context)
|
||||
const posB = this.computeConnectionPoint(link, t + 0.01, context) // slightly ahead for angle
|
||||
|
||||
const angle = Math.atan2(posB.y - posA.y, posB.x - posA.x)
|
||||
|
||||
// Draw arrow triangle (matching original shape)
|
||||
const transform = ctx.getTransform()
|
||||
ctx.translate(posA.x, posA.y)
|
||||
ctx.rotate(angle)
|
||||
ctx.fillStyle = color
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-5, -3)
|
||||
ctx.lineTo(0, +7)
|
||||
ctx.lineTo(+5, -3)
|
||||
ctx.fill()
|
||||
ctx.setTransform(transform)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a point along the link path at position t (0 to 1)
|
||||
* For backward compatibility with original litegraph, this always uses
|
||||
* bezier calculation with spline offsets, regardless of render mode.
|
||||
* This ensures arrow positions match the original implementation.
|
||||
*/
|
||||
private computeConnectionPoint(
|
||||
link: LinkRenderData,
|
||||
t: number,
|
||||
_context: RenderContext
|
||||
): Point {
|
||||
const { startPoint, endPoint, startDirection, endDirection } = link
|
||||
|
||||
// Match original behavior: always use bezier math with spline offsets
|
||||
// regardless of render mode (for arrow position compatibility)
|
||||
const dist = Math.sqrt(
|
||||
Math.pow(endPoint.x - startPoint.x, 2) +
|
||||
Math.pow(endPoint.y - startPoint.y, 2)
|
||||
)
|
||||
const factor = 0.25
|
||||
|
||||
// Create control points with spline offsets (matching original #addSplineOffset)
|
||||
const pa = { x: startPoint.x, y: startPoint.y }
|
||||
const pb = { x: endPoint.x, y: endPoint.y }
|
||||
|
||||
// Apply spline offsets based on direction
|
||||
switch (startDirection) {
|
||||
case 'left':
|
||||
pa.x -= dist * factor
|
||||
break
|
||||
case 'right':
|
||||
pa.x += dist * factor
|
||||
break
|
||||
case 'up':
|
||||
pa.y -= dist * factor
|
||||
break
|
||||
case 'down':
|
||||
pa.y += dist * factor
|
||||
break
|
||||
case 'none':
|
||||
break
|
||||
}
|
||||
|
||||
switch (endDirection) {
|
||||
case 'left':
|
||||
pb.x -= dist * factor
|
||||
break
|
||||
case 'right':
|
||||
pb.x += dist * factor
|
||||
break
|
||||
case 'up':
|
||||
pb.y -= dist * factor
|
||||
break
|
||||
case 'down':
|
||||
pb.y += dist * factor
|
||||
break
|
||||
case 'none':
|
||||
break
|
||||
}
|
||||
|
||||
// Calculate bezier point (matching original computeConnectionPoint)
|
||||
const c1 = (1 - t) * (1 - t) * (1 - t)
|
||||
const c2 = 3 * ((1 - t) * (1 - t)) * t
|
||||
const c3 = 3 * (1 - t) * (t * t)
|
||||
const c4 = t * t * t
|
||||
|
||||
return {
|
||||
x: c1 * startPoint.x + c2 * pa.x + c3 * pb.x + c4 * endPoint.x,
|
||||
y: c1 * startPoint.y + c2 * pa.y + c3 * pb.y + c4 * endPoint.y
|
||||
}
|
||||
}
|
||||
|
||||
private drawFlowAnimation(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_path: Path2D,
|
||||
link: LinkRenderData,
|
||||
context: RenderContext
|
||||
): void {
|
||||
if (!context.animation) return
|
||||
|
||||
// Match original implementation: render 5 moving circles along the path
|
||||
const time = context.animation.time
|
||||
const linkColor = this.determineLinkColor(link, context, false)
|
||||
|
||||
ctx.save()
|
||||
ctx.fillStyle = linkColor
|
||||
|
||||
// Draw 5 circles at different positions along the path
|
||||
for (let i = 0; i < 5; ++i) {
|
||||
// Calculate position along path (0 to 1), with time-based animation
|
||||
const f = (time + i * 0.2) % 1
|
||||
const flowPos = this.computeConnectionPoint(link, f, context)
|
||||
|
||||
// Draw circle at this position
|
||||
ctx.beginPath()
|
||||
ctx.arc(flowPos.x, flowPos.y, 5, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to find a point on a bezier curve (for hit detection)
|
||||
*/
|
||||
findPointOnBezier(
|
||||
t: number,
|
||||
p0: Point,
|
||||
p1: Point,
|
||||
p2: Point,
|
||||
p3: Point
|
||||
): Point {
|
||||
const mt = 1 - t
|
||||
const mt2 = mt * mt
|
||||
const mt3 = mt2 * mt
|
||||
const t2 = t * t
|
||||
const t3 = t2 * t
|
||||
|
||||
return {
|
||||
x: mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x,
|
||||
y: mt3 * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t3 * p3.y
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a link being dragged from a slot to the mouse position
|
||||
* Returns a Path2D object for potential hit detection
|
||||
*/
|
||||
drawDraggingLink(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
dragData: DragLinkData,
|
||||
context: RenderContext
|
||||
): Path2D {
|
||||
// Create LinkRenderData from drag data
|
||||
// When dragging from input, swap the points/directions
|
||||
const linkData: LinkRenderData = dragData.fromInput
|
||||
? {
|
||||
id: 'dragging',
|
||||
startPoint: dragData.dragPoint,
|
||||
endPoint: dragData.fixedPoint,
|
||||
startDirection:
|
||||
dragData.dragDirection ||
|
||||
this.getOppositeDirection(dragData.fixedDirection),
|
||||
endDirection: dragData.fixedDirection,
|
||||
color: dragData.color,
|
||||
type: dragData.type,
|
||||
disabled: dragData.disabled
|
||||
}
|
||||
: {
|
||||
id: 'dragging',
|
||||
startPoint: dragData.fixedPoint,
|
||||
endPoint: dragData.dragPoint,
|
||||
startDirection: dragData.fixedDirection,
|
||||
endDirection:
|
||||
dragData.dragDirection ||
|
||||
this.getOppositeDirection(dragData.fixedDirection),
|
||||
color: dragData.color,
|
||||
type: dragData.type,
|
||||
disabled: dragData.disabled
|
||||
}
|
||||
|
||||
// Use standard link drawing
|
||||
return this.drawLink(ctx, linkData, context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the opposite direction (for drag preview)
|
||||
*/
|
||||
private getOppositeDirection(direction: Direction): Direction {
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
return 'right'
|
||||
case 'right':
|
||||
return 'left'
|
||||
case 'up':
|
||||
return 'down'
|
||||
case 'down':
|
||||
return 'up'
|
||||
case 'none':
|
||||
default:
|
||||
return 'none'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the center point of a link (useful for labels, debugging)
|
||||
*/
|
||||
getLinkCenter(link: LinkRenderData): Point {
|
||||
// For now, simple midpoint
|
||||
// Could be enhanced to find actual curve midpoint
|
||||
return {
|
||||
x: (link.startPoint.x + link.endPoint.x) / 2,
|
||||
y: (link.startPoint.y + link.endPoint.y) / 2
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate and store the center point and angle of a link
|
||||
* Mimics the original litegraph center point calculation
|
||||
*/
|
||||
private calculateCenterPoint(
|
||||
link: LinkRenderData,
|
||||
context: RenderContext
|
||||
): void {
|
||||
const { startPoint, endPoint, controlPoints } = link
|
||||
|
||||
if (
|
||||
context.style.mode === 'spline' &&
|
||||
controlPoints &&
|
||||
controlPoints.length >= 2
|
||||
) {
|
||||
// For spline mode, find point at t=0.5 on the bezier curve
|
||||
const centerPos = this.findPointOnBezier(
|
||||
0.5,
|
||||
startPoint,
|
||||
controlPoints[0],
|
||||
controlPoints[1],
|
||||
endPoint
|
||||
)
|
||||
link.centerPos = centerPos
|
||||
|
||||
// Calculate angle for arrow marker (point slightly past center)
|
||||
if (context.style.centerMarkerShape === 'arrow') {
|
||||
const justPastCenter = this.findPointOnBezier(
|
||||
0.51,
|
||||
startPoint,
|
||||
controlPoints[0],
|
||||
controlPoints[1],
|
||||
endPoint
|
||||
)
|
||||
link.centerAngle = Math.atan2(
|
||||
justPastCenter.y - centerPos.y,
|
||||
justPastCenter.x - centerPos.x
|
||||
)
|
||||
}
|
||||
} else if (context.style.mode === 'linear') {
|
||||
// For linear mode, calculate midpoint between control points (matching original)
|
||||
const l = 15 // Same offset as buildLinearPath
|
||||
const innerA = { x: startPoint.x, y: startPoint.y }
|
||||
const innerB = { x: endPoint.x, y: endPoint.y }
|
||||
|
||||
// Apply same directional offsets as buildLinearPath
|
||||
switch (link.startDirection) {
|
||||
case 'left':
|
||||
innerA.x -= l
|
||||
break
|
||||
case 'right':
|
||||
innerA.x += l
|
||||
break
|
||||
case 'up':
|
||||
innerA.y -= l
|
||||
break
|
||||
case 'down':
|
||||
innerA.y += l
|
||||
break
|
||||
}
|
||||
|
||||
switch (link.endDirection) {
|
||||
case 'left':
|
||||
innerB.x -= l
|
||||
break
|
||||
case 'right':
|
||||
innerB.x += l
|
||||
break
|
||||
case 'up':
|
||||
innerB.y -= l
|
||||
break
|
||||
case 'down':
|
||||
innerB.y += l
|
||||
break
|
||||
}
|
||||
|
||||
link.centerPos = {
|
||||
x: (innerA.x + innerB.x) * 0.5,
|
||||
y: (innerA.y + innerB.y) * 0.5
|
||||
}
|
||||
|
||||
if (context.style.centerMarkerShape === 'arrow') {
|
||||
link.centerAngle = Math.atan2(innerB.y - innerA.y, innerB.x - innerA.x)
|
||||
}
|
||||
} else if (context.style.mode === 'straight') {
|
||||
// For straight mode, match original STRAIGHT_LINK center calculation
|
||||
const l = 10 // Same offset as buildStraightPath
|
||||
const innerA = { x: startPoint.x, y: startPoint.y }
|
||||
const innerB = { x: endPoint.x, y: endPoint.y }
|
||||
|
||||
// Apply same directional offsets as buildStraightPath
|
||||
switch (link.startDirection) {
|
||||
case 'left':
|
||||
innerA.x -= l
|
||||
break
|
||||
case 'right':
|
||||
innerA.x += l
|
||||
break
|
||||
case 'up':
|
||||
innerA.y -= l
|
||||
break
|
||||
case 'down':
|
||||
innerA.y += l
|
||||
break
|
||||
}
|
||||
|
||||
switch (link.endDirection) {
|
||||
case 'left':
|
||||
innerB.x -= l
|
||||
break
|
||||
case 'right':
|
||||
innerB.x += l
|
||||
break
|
||||
case 'up':
|
||||
innerB.y -= l
|
||||
break
|
||||
case 'down':
|
||||
innerB.y += l
|
||||
break
|
||||
}
|
||||
|
||||
// Calculate center using midX and average of innerA/innerB y positions
|
||||
const midX = (innerA.x + innerB.x) * 0.5
|
||||
link.centerPos = {
|
||||
x: midX,
|
||||
y: (innerA.y + innerB.y) * 0.5
|
||||
}
|
||||
|
||||
if (context.style.centerMarkerShape === 'arrow') {
|
||||
const diff = innerB.y - innerA.y
|
||||
if (Math.abs(diff) < 4) {
|
||||
link.centerAngle = 0
|
||||
} else if (diff > 0) {
|
||||
link.centerAngle = Math.PI * 0.5
|
||||
} else {
|
||||
link.centerAngle = -(Math.PI * 0.5)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to simple midpoint
|
||||
link.centerPos = this.getLinkCenter(link)
|
||||
if (context.style.centerMarkerShape === 'arrow') {
|
||||
link.centerAngle = Math.atan2(
|
||||
endPoint.y - startPoint.y,
|
||||
endPoint.x - startPoint.x
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the center marker on a link (for menu interaction)
|
||||
* Matches the original litegraph center marker rendering
|
||||
*/
|
||||
private drawCenterMarker(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
link: LinkRenderData,
|
||||
context: RenderContext,
|
||||
color: string
|
||||
): void {
|
||||
if (!link.centerPos) return
|
||||
|
||||
ctx.beginPath()
|
||||
|
||||
if (
|
||||
context.style.centerMarkerShape === 'arrow' &&
|
||||
link.centerAngle !== undefined
|
||||
) {
|
||||
const transform = ctx.getTransform()
|
||||
ctx.translate(link.centerPos.x, link.centerPos.y)
|
||||
ctx.rotate(link.centerAngle)
|
||||
// The math is off, but it currently looks better in chromium (from original)
|
||||
ctx.moveTo(-3.2, -5)
|
||||
ctx.lineTo(7, 0)
|
||||
ctx.lineTo(-3.2, 5)
|
||||
ctx.setTransform(transform)
|
||||
} else {
|
||||
// Default to circle
|
||||
ctx.arc(link.centerPos.x, link.centerPos.y, 5, 0, Math.PI * 2)
|
||||
}
|
||||
|
||||
// Apply disabled pattern or color
|
||||
if (link.disabled && context.patterns?.disabled) {
|
||||
const { fillStyle, globalAlpha } = ctx
|
||||
ctx.fillStyle = context.patterns.disabled
|
||||
ctx.globalAlpha = 0.75
|
||||
ctx.fill()
|
||||
ctx.globalAlpha = globalAlpha
|
||||
ctx.fillStyle = fillStyle
|
||||
} else {
|
||||
ctx.fillStyle = color
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
111
src/renderer/core/canvas/useCanvasInteractions.ts
Normal file
111
src/renderer/core/canvas/useCanvasInteractions.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
/**
|
||||
* Composable for handling canvas interactions from Vue components.
|
||||
* This provides a unified way to forward events to the LiteGraph canvas.
|
||||
*/
|
||||
export function useCanvasInteractions() {
|
||||
const settingStore = useSettingStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { getCanvas } = canvasStore
|
||||
|
||||
const isStandardNavMode = computed(
|
||||
() => settingStore.get('Comfy.Canvas.NavigationMode') === 'standard'
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether Vue node components should handle pointer events.
|
||||
* Returns false when canvas is in read-only/panning mode (e.g., space key held for panning).
|
||||
*/
|
||||
const shouldHandleNodePointerEvents = computed(
|
||||
() => !(canvasStore.canvas?.read_only ?? false)
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles wheel events from UI components that should be forwarded to canvas
|
||||
* when appropriate (e.g., Ctrl+wheel for zoom in standard mode)
|
||||
*/
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
// In standard mode, Ctrl+wheel should go to canvas for zoom
|
||||
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// In legacy mode, all wheel events go to canvas for zoom
|
||||
if (!isStandardNavMode.value) {
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, let the component handle it normally
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles pointer events from media elements that should potentially
|
||||
* be forwarded to canvas (e.g., space+drag for panning)
|
||||
*/
|
||||
const handlePointer = (event: PointerEvent) => {
|
||||
// Check if canvas exists using established pattern
|
||||
const canvas = getCanvas()
|
||||
if (!canvas) return
|
||||
|
||||
// Check conditions for forwarding events to canvas
|
||||
const isSpacePanningDrag = canvas.read_only && event.buttons === 1 // Space key pressed + left mouse drag
|
||||
const isMiddleMousePanning = event.buttons === 4 // Middle mouse button for panning
|
||||
|
||||
if (isSpacePanningDrag || isMiddleMousePanning) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards an event to the LiteGraph canvas
|
||||
*/
|
||||
const forwardEventToCanvas = (
|
||||
event: WheelEvent | PointerEvent | MouseEvent
|
||||
) => {
|
||||
const canvasEl = app.canvas?.canvas
|
||||
if (!canvasEl) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (event instanceof WheelEvent) {
|
||||
const { clientX, clientY, deltaX, deltaY, ctrlKey, metaKey, shiftKey } =
|
||||
event
|
||||
canvasEl.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
clientX,
|
||||
clientY,
|
||||
deltaX,
|
||||
deltaY,
|
||||
ctrlKey,
|
||||
metaKey,
|
||||
shiftKey
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Create new event with same properties
|
||||
const EventConstructor = event.constructor as
|
||||
| typeof MouseEvent
|
||||
| typeof PointerEvent
|
||||
const newEvent = new EventConstructor(event.type, event)
|
||||
canvasEl.dispatchEvent(newEvent)
|
||||
}
|
||||
|
||||
return {
|
||||
handleWheel,
|
||||
handlePointer,
|
||||
forwardEventToCanvas,
|
||||
shouldHandleNodePointerEvents
|
||||
}
|
||||
}
|
||||
278
src/renderer/core/layout/__tests__/TransformPane.test.ts
Normal file
278
src/renderer/core/layout/__tests__/TransformPane.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
|
||||
import TransformPane from '../transform/TransformPane.vue'
|
||||
|
||||
const mockData = vi.hoisted(() => ({
|
||||
mockTransformStyle: {
|
||||
transform: 'scale(1) translate(0px, 0px)',
|
||||
transformOrigin: '0 0'
|
||||
},
|
||||
mockCamera: { x: 0, y: 0, z: 1 }
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
|
||||
const syncWithCanvas = vi.fn()
|
||||
return {
|
||||
useTransformState: () => ({
|
||||
camera: computed(() => mockData.mockCamera),
|
||||
transformStyle: computed(() => mockData.mockTransformStyle),
|
||||
canvasToScreen: vi.fn(),
|
||||
screenToCanvas: vi.fn(),
|
||||
isNodeInViewport: vi.fn(),
|
||||
syncWithCanvas
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({
|
||||
useLOD: vi.fn(() => ({
|
||||
isLOD: false
|
||||
}))
|
||||
}))
|
||||
|
||||
function createMockCanvas(): LGraphCanvas {
|
||||
return {
|
||||
canvas: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
},
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
}
|
||||
} as unknown as LGraphCanvas
|
||||
}
|
||||
|
||||
describe('TransformPane', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.resetAllMocks()
|
||||
|
||||
// Create mock canvas with LiteGraph interface
|
||||
})
|
||||
|
||||
describe('component mounting', () => {
|
||||
it('should mount successfully with minimal props', () => {
|
||||
const mockCanvas = createMockCanvas()
|
||||
const wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="transform-pane"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should apply transform style from composable', async () => {
|
||||
mockData.mockTransformStyle = {
|
||||
transform: 'scale(2) translate(100px, 50px)',
|
||||
transformOrigin: '0 0'
|
||||
}
|
||||
|
||||
const mockCanvas = createMockCanvas()
|
||||
const wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const transformPane = wrapper.find('[data-testid="transform-pane"]')
|
||||
const style = transformPane.attributes('style')
|
||||
expect(style).toContain('transform: scale(2) translate(100px, 50px)')
|
||||
})
|
||||
|
||||
it('should render slot content', () => {
|
||||
const mockCanvas = createMockCanvas()
|
||||
const wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
},
|
||||
slots: {
|
||||
default: '<div class="test-content">Test Node</div>'
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.find('.test-content').exists()).toBe(true)
|
||||
expect(wrapper.find('.test-content').text()).toBe('Test Node')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RAF synchronization', () => {
|
||||
it('should call syncWithCanvas during RAF updates', async () => {
|
||||
const mockCanvas = createMockCanvas()
|
||||
mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Allow RAF to execute
|
||||
vi.advanceTimersToNextFrame()
|
||||
|
||||
const transformState = useTransformState()
|
||||
expect(transformState.syncWithCanvas).toHaveBeenCalledWith(mockCanvas)
|
||||
})
|
||||
|
||||
it('should emit transform update timing', async () => {
|
||||
const mockCanvas = createMockCanvas()
|
||||
const wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Allow RAF to execute
|
||||
vi.advanceTimersToNextFrame()
|
||||
|
||||
expect(wrapper.emitted('transformUpdate')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('canvas event listeners', () => {
|
||||
it('should add event listeners to canvas on mount', async () => {
|
||||
const mockCanvas = createMockCanvas()
|
||||
mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
|
||||
'wheel',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
|
||||
'pointercancel',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('should remove event listeners on unmount', async () => {
|
||||
const mockCanvas = createMockCanvas()
|
||||
const wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
wrapper.unmount()
|
||||
|
||||
expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
|
||||
'wheel',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
|
||||
'pointercancel',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('interaction state management', () => {
|
||||
it('should apply interacting class during interactions', async () => {
|
||||
const mockCanvas = createMockCanvas()
|
||||
const wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate interaction start by checking internal state
|
||||
// Note: This tests the CSS class application logic
|
||||
const transformPane = wrapper.find('[data-testid="transform-pane"]')
|
||||
|
||||
// Initially should not have interacting class
|
||||
expect(transformPane.classes()).not.toContain(
|
||||
'transform-pane--interacting'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle pointer events for node delegation', async () => {
|
||||
const mockCanvas = createMockCanvas()
|
||||
const wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
const transformPane = wrapper.find('[data-testid="transform-pane"]')
|
||||
|
||||
// Simulate pointer down - we can't test the exact delegation logic
|
||||
// in unit tests due to vue-test-utils limitations, but we can verify
|
||||
// the event handler is set up correctly
|
||||
await transformPane.trigger('pointerdown')
|
||||
|
||||
// The test passes if no errors are thrown during event handling
|
||||
expect(transformPane.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transform state integration', () => {
|
||||
it('should provide transform utilities to child components', () => {
|
||||
const mockCanvas = createMockCanvas()
|
||||
mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
const transformState = useTransformState()
|
||||
// The component should provide transform state via Vue's provide/inject
|
||||
// This is tested indirectly through the composable integration
|
||||
expect(transformState.syncWithCanvas).toBeDefined()
|
||||
expect(transformState.canvasToScreen).toBeDefined()
|
||||
expect(transformState.screenToCanvas).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle null canvas gracefully', () => {
|
||||
const wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: undefined
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="transform-pane"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
50
src/renderer/core/layout/constants.ts
Normal file
50
src/renderer/core/layout/constants.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Layout System Constants
|
||||
*
|
||||
* Centralized configuration values for the layout system.
|
||||
* These values control spatial indexing, performance, and behavior.
|
||||
*/
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* QuadTree configuration for spatial indexing
|
||||
*/
|
||||
export const QUADTREE_CONFIG = {
|
||||
/** Default bounds for the QuadTree - covers a large canvas area */
|
||||
DEFAULT_BOUNDS: {
|
||||
x: -10000,
|
||||
y: -10000,
|
||||
width: 20000,
|
||||
height: 20000
|
||||
},
|
||||
/** Maximum tree depth to prevent excessive subdivision */
|
||||
MAX_DEPTH: 6,
|
||||
/** Maximum items per node before subdivision */
|
||||
MAX_ITEMS_PER_NODE: 4
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Performance and optimization settings
|
||||
*/
|
||||
export const PERFORMANCE_CONFIG = {
|
||||
/** RAF-based change detection interval (roughly 60fps) */
|
||||
CHANGE_DETECTION_INTERVAL: 16,
|
||||
/** Spatial query cache TTL in milliseconds */
|
||||
SPATIAL_CACHE_TTL: 1000,
|
||||
/** Maximum cache size for spatial queries */
|
||||
SPATIAL_CACHE_MAX_SIZE: 100,
|
||||
/** Batch update delay in milliseconds */
|
||||
BATCH_UPDATE_DELAY: 4
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Actor and source identifiers
|
||||
*/
|
||||
export const ACTOR_CONFIG = {
|
||||
/** Prefix for auto-generated actor IDs */
|
||||
USER_PREFIX: 'user-',
|
||||
/** Length of random suffix for actor IDs */
|
||||
ID_LENGTH: 9,
|
||||
/** Default source when not specified */
|
||||
DEFAULT_SOURCE: LayoutSource.External
|
||||
} as const
|
||||
31
src/renderer/core/layout/injectionKeys.ts
Normal file
31
src/renderer/core/layout/injectionKeys.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { InjectionKey } from 'vue'
|
||||
|
||||
import type { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
|
||||
/**
|
||||
* Lightweight, injectable transform state used by layout-aware components.
|
||||
*
|
||||
* Consumers use this interface to convert coordinates between LiteGraph's
|
||||
* canvas space and the DOM's screen space, access the current pan/zoom
|
||||
* (camera), and perform basic viewport culling checks.
|
||||
*
|
||||
* Coordinate mapping:
|
||||
* - screen = (canvas + offset) * scale
|
||||
* - canvas = screen / scale - offset
|
||||
*
|
||||
* The full implementation and additional helpers live in
|
||||
* `useTransformState()`. This interface deliberately exposes only the
|
||||
* minimal surface needed outside that composable.
|
||||
*
|
||||
* @example
|
||||
* const state = inject(TransformStateKey)!
|
||||
* const screen = state.canvasToScreen({ x: 100, y: 50 })
|
||||
*/
|
||||
interface TransformState
|
||||
extends Pick<
|
||||
ReturnType<typeof useTransformState>,
|
||||
'screenToCanvas' | 'canvasToScreen' | 'camera' | 'isNodeInViewport'
|
||||
> {}
|
||||
|
||||
export const TransformStateKey: InjectionKey<TransformState> =
|
||||
Symbol('transformState')
|
||||
340
src/renderer/core/layout/operations/layoutMutations.ts
Normal file
340
src/renderer/core/layout/operations/layoutMutations.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Layout Mutations - Simplified Direct Operations
|
||||
*
|
||||
* Provides a clean API for layout operations that are CRDT-ready.
|
||||
* Operations are synchronous and applied directly to the store.
|
||||
*/
|
||||
import log from 'loglevel'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type {
|
||||
LinkId,
|
||||
NodeLayout,
|
||||
Point,
|
||||
RerouteId,
|
||||
Size
|
||||
} from '@/renderer/core/layout/types'
|
||||
|
||||
const logger = log.getLogger('LayoutMutations')
|
||||
|
||||
interface LayoutMutations {
|
||||
// Single node operations (synchronous, CRDT-ready)
|
||||
moveNode(nodeId: NodeId, position: Point): void
|
||||
resizeNode(nodeId: NodeId, size: Size): void
|
||||
setNodeZIndex(nodeId: NodeId, zIndex: number): void
|
||||
|
||||
// Node lifecycle operations
|
||||
createNode(nodeId: NodeId, layout: Partial<NodeLayout>): void
|
||||
deleteNode(nodeId: NodeId): void
|
||||
|
||||
// Link operations
|
||||
createLink(
|
||||
linkId: LinkId,
|
||||
sourceNodeId: NodeId,
|
||||
sourceSlot: number,
|
||||
targetNodeId: NodeId,
|
||||
targetSlot: number
|
||||
): void
|
||||
deleteLink(linkId: LinkId): void
|
||||
|
||||
// Reroute operations
|
||||
createReroute(
|
||||
rerouteId: RerouteId,
|
||||
position: Point,
|
||||
parentId?: LinkId,
|
||||
linkIds?: LinkId[]
|
||||
): void
|
||||
deleteReroute(rerouteId: RerouteId): void
|
||||
moveReroute(
|
||||
rerouteId: RerouteId,
|
||||
position: Point,
|
||||
previousPosition: Point
|
||||
): void
|
||||
|
||||
// Stacking operations
|
||||
bringNodeToFront(nodeId: NodeId): void
|
||||
|
||||
// Source tracking
|
||||
setSource(source: LayoutSource): void
|
||||
setActor(actor: string): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for accessing layout mutations with clean destructuring API
|
||||
*/
|
||||
export function useLayoutMutations(): LayoutMutations {
|
||||
/**
|
||||
* Set the current mutation source
|
||||
*/
|
||||
const setSource = (source: LayoutSource): void => {
|
||||
layoutStore.setSource(source)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current actor (for CRDT)
|
||||
*/
|
||||
const setActor = (actor: string): void => {
|
||||
layoutStore.setActor(actor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a node to a new position
|
||||
*/
|
||||
const moveNode = (nodeId: NodeId, position: Point): void => {
|
||||
const normalizedNodeId = String(nodeId)
|
||||
const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
|
||||
if (!existing) return
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'moveNode',
|
||||
entity: 'node',
|
||||
nodeId: normalizedNodeId,
|
||||
position,
|
||||
previousPosition: existing.position,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a node
|
||||
*/
|
||||
const resizeNode = (nodeId: NodeId, size: Size): void => {
|
||||
const normalizedNodeId = String(nodeId)
|
||||
const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
|
||||
if (!existing) return
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'resizeNode',
|
||||
entity: 'node',
|
||||
nodeId: normalizedNodeId,
|
||||
size,
|
||||
previousSize: existing.size,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set node z-index
|
||||
*/
|
||||
const setNodeZIndex = (nodeId: NodeId, zIndex: number): void => {
|
||||
const normalizedNodeId = String(nodeId)
|
||||
const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
|
||||
if (!existing) return
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'setNodeZIndex',
|
||||
entity: 'node',
|
||||
nodeId: normalizedNodeId,
|
||||
zIndex,
|
||||
previousZIndex: existing.zIndex,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new node
|
||||
*/
|
||||
const createNode = (nodeId: NodeId, layout: Partial<NodeLayout>): void => {
|
||||
const normalizedNodeId = String(nodeId)
|
||||
const fullLayout: NodeLayout = {
|
||||
id: normalizedNodeId,
|
||||
position: layout.position ?? { x: 0, y: 0 },
|
||||
size: layout.size ?? { width: 200, height: 100 },
|
||||
zIndex: layout.zIndex ?? 0,
|
||||
visible: layout.visible ?? true,
|
||||
bounds: {
|
||||
x: layout.position?.x ?? 0,
|
||||
y: layout.position?.y ?? 0,
|
||||
width: layout.size?.width ?? 200,
|
||||
height: layout.size?.height ?? 100
|
||||
}
|
||||
}
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId: normalizedNodeId,
|
||||
layout: fullLayout,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a node
|
||||
*/
|
||||
const deleteNode = (nodeId: NodeId): void => {
|
||||
const normalizedNodeId = String(nodeId)
|
||||
const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
|
||||
if (!existing) return
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'deleteNode',
|
||||
entity: 'node',
|
||||
nodeId: normalizedNodeId,
|
||||
previousLayout: existing,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring a node to the front (highest z-index)
|
||||
*/
|
||||
const bringNodeToFront = (nodeId: NodeId): void => {
|
||||
// Get all nodes to find the highest z-index
|
||||
const allNodes = layoutStore.getAllNodes().value
|
||||
let maxZIndex = 0
|
||||
|
||||
for (const [, layout] of allNodes) {
|
||||
if (layout.zIndex > maxZIndex) {
|
||||
maxZIndex = layout.zIndex
|
||||
}
|
||||
}
|
||||
|
||||
// Set this node's z-index to be one higher than the current max
|
||||
setNodeZIndex(nodeId, maxZIndex + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new link
|
||||
*/
|
||||
const createLink = (
|
||||
linkId: LinkId,
|
||||
sourceNodeId: NodeId,
|
||||
sourceSlot: number,
|
||||
targetNodeId: NodeId,
|
||||
targetSlot: number
|
||||
): void => {
|
||||
// Normalize node IDs to strings for layout store consistency
|
||||
const normalizedSourceNodeId = String(sourceNodeId)
|
||||
const normalizedTargetNodeId = String(targetNodeId)
|
||||
|
||||
logger.debug('Creating link:', {
|
||||
linkId,
|
||||
from: `${normalizedSourceNodeId}[${sourceSlot}]`,
|
||||
to: `${normalizedTargetNodeId}[${targetSlot}]`
|
||||
})
|
||||
layoutStore.applyOperation({
|
||||
type: 'createLink',
|
||||
entity: 'link',
|
||||
linkId,
|
||||
sourceNodeId: normalizedSourceNodeId,
|
||||
sourceSlot,
|
||||
targetNodeId: normalizedTargetNodeId,
|
||||
targetSlot,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a link
|
||||
*/
|
||||
const deleteLink = (linkId: LinkId): void => {
|
||||
logger.debug('Deleting link:', linkId)
|
||||
layoutStore.applyOperation({
|
||||
type: 'deleteLink',
|
||||
entity: 'link',
|
||||
linkId,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new reroute
|
||||
*/
|
||||
const createReroute = (
|
||||
rerouteId: RerouteId,
|
||||
position: Point,
|
||||
parentId?: LinkId,
|
||||
linkIds: LinkId[] = []
|
||||
): void => {
|
||||
logger.debug('Creating reroute:', {
|
||||
rerouteId,
|
||||
position,
|
||||
parentId,
|
||||
linkCount: linkIds.length
|
||||
})
|
||||
layoutStore.applyOperation({
|
||||
type: 'createReroute',
|
||||
entity: 'reroute',
|
||||
rerouteId,
|
||||
position,
|
||||
parentId,
|
||||
linkIds,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a reroute
|
||||
*/
|
||||
const deleteReroute = (rerouteId: RerouteId): void => {
|
||||
logger.debug('Deleting reroute:', rerouteId)
|
||||
layoutStore.applyOperation({
|
||||
type: 'deleteReroute',
|
||||
entity: 'reroute',
|
||||
rerouteId,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a reroute
|
||||
*/
|
||||
const moveReroute = (
|
||||
rerouteId: RerouteId,
|
||||
position: Point,
|
||||
previousPosition: Point
|
||||
): void => {
|
||||
logger.debug('Moving reroute:', {
|
||||
rerouteId,
|
||||
from: previousPosition,
|
||||
to: position
|
||||
})
|
||||
layoutStore.applyOperation({
|
||||
type: 'moveReroute',
|
||||
entity: 'reroute',
|
||||
rerouteId,
|
||||
position,
|
||||
previousPosition,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
setSource,
|
||||
setActor,
|
||||
moveNode,
|
||||
resizeNode,
|
||||
setNodeZIndex,
|
||||
createNode,
|
||||
deleteNode,
|
||||
bringNodeToFront,
|
||||
createLink,
|
||||
deleteLink,
|
||||
createReroute,
|
||||
deleteReroute,
|
||||
moveReroute
|
||||
}
|
||||
}
|
||||
75
src/renderer/core/layout/slots/register.ts
Normal file
75
src/renderer/core/layout/slots/register.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Slot Registration
|
||||
*
|
||||
* Handles registration of slot layouts with the layout store for hit testing.
|
||||
* This module manages the state mutation side of slot layout management,
|
||||
* while pure calculations are handled separately in SlotCalculations.ts.
|
||||
*/
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
type SlotPositionContext,
|
||||
calculateInputSlotPos,
|
||||
calculateOutputSlotPos
|
||||
} from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { SlotLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
import { getSlotKey } from './slotIdentifier'
|
||||
|
||||
/**
|
||||
* Register slot layout with the layout store for hit testing
|
||||
* @param nodeId The node ID
|
||||
* @param slotIndex The slot index
|
||||
* @param isInput Whether this is an input slot
|
||||
* @param position The slot position in graph coordinates
|
||||
*/
|
||||
function registerSlotLayout(
|
||||
nodeId: string,
|
||||
slotIndex: number,
|
||||
isInput: boolean,
|
||||
position: Point
|
||||
): void {
|
||||
const slotKey = getSlotKey(nodeId, slotIndex, isInput)
|
||||
|
||||
// Calculate bounds for the slot using LiteGraph's standard slot height
|
||||
const slotSize = LiteGraph.NODE_SLOT_HEIGHT
|
||||
const halfSize = slotSize / 2
|
||||
|
||||
const slotLayout: SlotLayout = {
|
||||
nodeId,
|
||||
index: slotIndex,
|
||||
type: isInput ? 'input' : 'output',
|
||||
position: { x: position[0], y: position[1] },
|
||||
bounds: {
|
||||
x: position[0] - halfSize,
|
||||
y: position[1] - halfSize,
|
||||
width: slotSize,
|
||||
height: slotSize
|
||||
}
|
||||
}
|
||||
|
||||
layoutStore.updateSlotLayout(slotKey, slotLayout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all slots for a node
|
||||
* @param nodeId The node ID
|
||||
* @param context The slot position context
|
||||
*/
|
||||
export function registerNodeSlots(
|
||||
nodeId: string,
|
||||
context: SlotPositionContext
|
||||
): void {
|
||||
// Register input slots
|
||||
context.inputs.forEach((_, index) => {
|
||||
const position = calculateInputSlotPos(context, index)
|
||||
registerSlotLayout(nodeId, index, true, position)
|
||||
})
|
||||
|
||||
// Register output slots
|
||||
context.outputs.forEach((_, index) => {
|
||||
const position = calculateOutputSlotPos(context, index)
|
||||
registerSlotLayout(nodeId, index, false, position)
|
||||
})
|
||||
}
|
||||
40
src/renderer/core/layout/slots/slotIdentifier.ts
Normal file
40
src/renderer/core/layout/slots/slotIdentifier.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Slot identifier utilities for consistent slot key generation and parsing
|
||||
*
|
||||
* Provides a centralized interface for slot identification across the layout system
|
||||
*
|
||||
* @TODO Replace this concatenated string with root cause fix
|
||||
*/
|
||||
|
||||
interface SlotIdentifier {
|
||||
nodeId: string
|
||||
index: number
|
||||
isInput: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique key for a slot
|
||||
* Format: "{nodeId}-{in|out}-{index}"
|
||||
*/
|
||||
export function getSlotKey(identifier: SlotIdentifier): string
|
||||
export function getSlotKey(
|
||||
nodeId: string,
|
||||
index: number,
|
||||
isInput: boolean
|
||||
): string
|
||||
export function getSlotKey(
|
||||
nodeIdOrIdentifier: string | SlotIdentifier,
|
||||
index?: number,
|
||||
isInput?: boolean
|
||||
): string {
|
||||
if (typeof nodeIdOrIdentifier === 'object') {
|
||||
const { nodeId, index, isInput } = nodeIdOrIdentifier
|
||||
return `${nodeId}-${isInput ? 'in' : 'out'}-${index}`
|
||||
}
|
||||
|
||||
if (index === undefined || isInput === undefined) {
|
||||
throw new Error('Missing required parameters for slot key generation')
|
||||
}
|
||||
|
||||
return `${nodeIdOrIdentifier}-${isInput ? 'in' : 'out'}-${index}`
|
||||
}
|
||||
1393
src/renderer/core/layout/store/layoutStore.ts
Normal file
1393
src/renderer/core/layout/store/layoutStore.ts
Normal file
File diff suppressed because it is too large
Load Diff
72
src/renderer/core/layout/sync/useLayoutSync.ts
Normal file
72
src/renderer/core/layout/sync/useLayoutSync.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Composable for syncing LiteGraph with the Layout system
|
||||
*
|
||||
* Implements one-way sync from Layout Store to LiteGraph.
|
||||
* The layout store is the single source of truth.
|
||||
*/
|
||||
import { onUnmounted } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
/**
|
||||
* Composable for syncing LiteGraph with the Layout system
|
||||
* This replaces the bidirectional sync with a one-way sync
|
||||
*/
|
||||
export function useLayoutSync() {
|
||||
const unsubscribe = ref<() => void>()
|
||||
|
||||
/**
|
||||
* Start syncing from Layout → LiteGraph
|
||||
*/
|
||||
function startSync(canvas: ReturnType<typeof useCanvasStore>['canvas']) {
|
||||
if (!canvas?.graph) return
|
||||
|
||||
// Cancel last subscription
|
||||
stopSync()
|
||||
// Subscribe to layout changes
|
||||
unsubscribe.value = layoutStore.onChange((change) => {
|
||||
// Apply changes to LiteGraph regardless of source
|
||||
// The layout store is the single source of truth
|
||||
for (const nodeId of change.nodeIds) {
|
||||
const layout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (!layout) continue
|
||||
|
||||
const liteNode = canvas.graph?.getNodeById(parseInt(nodeId))
|
||||
if (!liteNode) continue
|
||||
|
||||
if (
|
||||
liteNode.pos[0] !== layout.position.x ||
|
||||
liteNode.pos[1] !== layout.position.y
|
||||
) {
|
||||
liteNode.pos[0] = layout.position.x
|
||||
liteNode.pos[1] = layout.position.y
|
||||
}
|
||||
|
||||
if (
|
||||
liteNode.size[0] !== layout.size.width ||
|
||||
liteNode.size[1] !== layout.size.height
|
||||
) {
|
||||
liteNode.size[0] = layout.size.width
|
||||
liteNode.size[1] = layout.size.height
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger single redraw for all changes
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
}
|
||||
|
||||
function stopSync() {
|
||||
unsubscribe.value?.()
|
||||
unsubscribe.value = undefined
|
||||
}
|
||||
|
||||
onUnmounted(stopSync)
|
||||
|
||||
return {
|
||||
startSync,
|
||||
stopSync
|
||||
}
|
||||
}
|
||||
305
src/renderer/core/layout/sync/useLinkLayoutSync.ts
Normal file
305
src/renderer/core/layout/sync/useLinkLayoutSync.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { tryOnScopeDispose } from '@vueuse/core'
|
||||
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 { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { LayoutChange } from '@/renderer/core/layout/types'
|
||||
|
||||
export function useLinkLayoutSync() {
|
||||
const canvasRef = ref<LGraphCanvas>()
|
||||
const graphRef = computed(() => canvasRef.value?.graph)
|
||||
const unsubscribeLayoutChange = ref<() => void>()
|
||||
const adapter = new LitegraphLinkAdapter()
|
||||
|
||||
/**
|
||||
* Build link render context from canvas properties
|
||||
*/
|
||||
function buildLinkRenderContext(): LinkRenderContext {
|
||||
const canvas = toValue(canvasRef)
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not initialized')
|
||||
}
|
||||
|
||||
return {
|
||||
// Canvas settings
|
||||
renderMode: canvas.links_render_mode,
|
||||
connectionWidth: canvas.connections_width,
|
||||
renderBorder: canvas.render_connections_border,
|
||||
lowQuality: canvas.low_quality,
|
||||
highQualityRender: canvas.highquality_render,
|
||||
scale: canvas.ds.scale,
|
||||
linkMarkerShape: canvas.linkMarkerShape,
|
||||
renderConnectionArrows: canvas.render_connection_arrows,
|
||||
|
||||
// State
|
||||
highlightedLinks: new Set(Object.keys(canvas.highlighted_links)),
|
||||
|
||||
// Colors
|
||||
defaultLinkColor: canvas.default_link_color,
|
||||
linkTypeColors: (canvas.constructor as any).link_type_colors || {},
|
||||
|
||||
// Pattern for disabled links
|
||||
disabledPattern: canvas._pattern
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute a single link and all its segments
|
||||
*
|
||||
* Note: This logic mirrors LGraphCanvas#renderAllLinkSegments but:
|
||||
* - Works with offscreen context for event-driven updates
|
||||
* - No visibility checks (always computes full geometry)
|
||||
* - No dragging state handling (pure geometry computation)
|
||||
*/
|
||||
function recomputeLinkById(linkId: number): void {
|
||||
const canvas = toValue(canvasRef)
|
||||
const graph = toValue(graphRef)
|
||||
if (!graph || !canvas) return
|
||||
|
||||
const link = graph.links.get(linkId)
|
||||
if (!link || link.id === -1) return // Skip floating/temp links
|
||||
|
||||
// Get source and target nodes
|
||||
const sourceNode = graph.getNodeById(link.origin_id)
|
||||
const targetNode = graph.getNodeById(link.target_id)
|
||||
if (!sourceNode || !targetNode) return
|
||||
|
||||
// Get slots
|
||||
const sourceSlot = sourceNode.outputs?.[link.origin_slot]
|
||||
const targetSlot = targetNode.inputs?.[link.target_slot]
|
||||
if (!sourceSlot || !targetSlot) return
|
||||
|
||||
// Get positions
|
||||
const startPos = getSlotPosition(sourceNode, link.origin_slot, false)
|
||||
const endPos = getSlotPosition(targetNode, link.target_slot, true)
|
||||
|
||||
// Get directions
|
||||
const startDir = sourceSlot.dir || LinkDirection.RIGHT
|
||||
const endDir = targetSlot.dir || LinkDirection.LEFT
|
||||
|
||||
// Get reroutes for this link
|
||||
const reroutes = LLink.getReroutes(graph, link)
|
||||
|
||||
// Build render context
|
||||
const context = buildLinkRenderContext()
|
||||
|
||||
if (reroutes.length > 0) {
|
||||
// Render segmented link with reroutes
|
||||
let segmentStartPos = startPos
|
||||
let segmentStartDir = startDir
|
||||
|
||||
for (let i = 0; i < reroutes.length; i++) {
|
||||
const reroute = reroutes[i]
|
||||
|
||||
// Calculate reroute angle
|
||||
reroute.calculateAngle(Date.now(), graph, [
|
||||
segmentStartPos[0],
|
||||
segmentStartPos[1]
|
||||
])
|
||||
|
||||
// Calculate control points
|
||||
const distance = Math.sqrt(
|
||||
(reroute.pos[0] - segmentStartPos[0]) ** 2 +
|
||||
(reroute.pos[1] - segmentStartPos[1]) ** 2
|
||||
)
|
||||
const dist = Math.min(Reroute.maxSplineOffset, distance * 0.25)
|
||||
|
||||
// Special handling for floating input chain
|
||||
const isFloatingInputChain = !sourceNode && targetNode
|
||||
const startControl: ReadOnlyPoint = isFloatingInputChain
|
||||
? [0, 0]
|
||||
: [dist * reroute.cos, dist * reroute.sin]
|
||||
|
||||
// Render segment to this reroute
|
||||
adapter.renderLinkDirect(
|
||||
canvas.ctx,
|
||||
segmentStartPos,
|
||||
reroute.pos,
|
||||
link,
|
||||
true, // skip_border
|
||||
0, // flow
|
||||
null, // color
|
||||
segmentStartDir,
|
||||
LinkDirection.CENTER,
|
||||
context,
|
||||
{
|
||||
startControl,
|
||||
endControl: reroute.controlPoint,
|
||||
reroute,
|
||||
disabled: false
|
||||
}
|
||||
)
|
||||
|
||||
// Prepare for next segment
|
||||
segmentStartPos = reroute.pos
|
||||
segmentStartDir = LinkDirection.CENTER
|
||||
}
|
||||
|
||||
// Render final segment from last reroute to target
|
||||
const lastReroute = reroutes[reroutes.length - 1]
|
||||
const finalDistance = Math.sqrt(
|
||||
(endPos[0] - lastReroute.pos[0]) ** 2 +
|
||||
(endPos[1] - lastReroute.pos[1]) ** 2
|
||||
)
|
||||
const finalDist = Math.min(Reroute.maxSplineOffset, finalDistance * 0.25)
|
||||
const finalStartControl: ReadOnlyPoint = [
|
||||
finalDist * lastReroute.cos,
|
||||
finalDist * lastReroute.sin
|
||||
]
|
||||
|
||||
adapter.renderLinkDirect(
|
||||
canvas.ctx,
|
||||
lastReroute.pos,
|
||||
endPos,
|
||||
link,
|
||||
true, // skip_border
|
||||
0, // flow
|
||||
null, // color
|
||||
LinkDirection.CENTER,
|
||||
endDir,
|
||||
context,
|
||||
{
|
||||
startControl: finalStartControl,
|
||||
disabled: false
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// No reroutes - render direct link
|
||||
adapter.renderLinkDirect(
|
||||
canvas.ctx,
|
||||
startPos,
|
||||
endPos,
|
||||
link,
|
||||
true, // skip_border
|
||||
0, // flow
|
||||
null, // color
|
||||
startDir,
|
||||
endDir,
|
||||
context,
|
||||
{
|
||||
disabled: false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute all links connected to a node
|
||||
*/
|
||||
function recomputeLinksForNode(nodeId: number): void {
|
||||
const graph = toValue(graphRef)
|
||||
if (!graph) return
|
||||
|
||||
const node = graph.getNodeById(nodeId)
|
||||
if (!node) return
|
||||
|
||||
const linkIds = new Set<number>()
|
||||
|
||||
// Collect output links
|
||||
if (node.outputs) {
|
||||
for (const output of node.outputs) {
|
||||
if (output.links) {
|
||||
for (const linkId of output.links) {
|
||||
linkIds.add(linkId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect input links
|
||||
if (node.inputs) {
|
||||
for (const input of node.inputs) {
|
||||
if (input.link !== null && input.link !== undefined) {
|
||||
linkIds.add(input.link)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recompute each link
|
||||
for (const linkId of linkIds) {
|
||||
recomputeLinkById(linkId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute all links associated with a reroute
|
||||
*/
|
||||
function recomputeLinksForReroute(rerouteId: number): void {
|
||||
const graph = toValue(graphRef)
|
||||
if (!graph) return
|
||||
|
||||
const reroute = graph.reroutes.get(rerouteId)
|
||||
if (!reroute) return
|
||||
|
||||
// Recompute all links that pass through this reroute
|
||||
for (const linkId of reroute.linkIds) {
|
||||
recomputeLinkById(linkId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start link layout sync with event-driven functionality
|
||||
*/
|
||||
function start(canvasInstance: LGraphCanvas): void {
|
||||
canvasRef.value = canvasInstance
|
||||
if (!canvasInstance.graph) return
|
||||
|
||||
// Initial computation for all existing links
|
||||
for (const link of canvasInstance.graph._links.values()) {
|
||||
if (link.id !== -1) {
|
||||
recomputeLinkById(link.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to layout store changes
|
||||
unsubscribeLayoutChange.value?.()
|
||||
unsubscribeLayoutChange.value = layoutStore.onChange(
|
||||
(change: LayoutChange) => {
|
||||
switch (change.operation.type) {
|
||||
case 'moveNode':
|
||||
case 'resizeNode':
|
||||
recomputeLinksForNode(parseInt(change.operation.nodeId))
|
||||
break
|
||||
case 'createLink':
|
||||
recomputeLinkById(change.operation.linkId)
|
||||
break
|
||||
case 'deleteLink':
|
||||
// No-op - store already cleaned by existing code
|
||||
break
|
||||
case 'createReroute':
|
||||
case 'deleteReroute':
|
||||
// Recompute all affected links
|
||||
if ('linkIds' in change.operation) {
|
||||
for (const linkId of change.operation.linkIds) {
|
||||
recomputeLinkById(linkId)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'moveReroute':
|
||||
recomputeLinksForReroute(change.operation.rerouteId)
|
||||
break
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
unsubscribeLayoutChange.value?.()
|
||||
unsubscribeLayoutChange.value = undefined
|
||||
canvasRef.value = undefined
|
||||
}
|
||||
|
||||
tryOnScopeDispose(stop)
|
||||
|
||||
return {
|
||||
start,
|
||||
stop
|
||||
}
|
||||
}
|
||||
150
src/renderer/core/layout/sync/useSlotLayoutSync.ts
Normal file
150
src/renderer/core/layout/sync/useSlotLayoutSync.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { tryOnScopeDispose } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { registerNodeSlots } from '@/renderer/core/layout/slots/register'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
function computeAndRegisterSlots(node: LGraphNode): void {
|
||||
const nodeId = String(node.id)
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
|
||||
// Fallback to live node values if layout not ready
|
||||
const nodeX = nodeLayout?.position.x ?? node.pos[0]
|
||||
const nodeY = nodeLayout?.position.y ?? node.pos[1]
|
||||
const nodeWidth = nodeLayout?.size.width ?? node.size[0]
|
||||
const nodeHeight = nodeLayout?.size.height ?? node.size[1]
|
||||
|
||||
// Ensure concrete slots & arrange when needed for accurate positions
|
||||
node._setConcreteSlots()
|
||||
const collapsed = node.flags.collapsed ?? false
|
||||
if (!collapsed) {
|
||||
node.arrange()
|
||||
}
|
||||
|
||||
const context: SlotPositionContext = {
|
||||
nodeX,
|
||||
nodeY,
|
||||
nodeWidth,
|
||||
nodeHeight,
|
||||
collapsed,
|
||||
collapsedWidth: node._collapsed_width,
|
||||
slotStartY: node.constructor.slot_start_y,
|
||||
inputs: node.inputs,
|
||||
outputs: node.outputs,
|
||||
widgets: node.widgets
|
||||
}
|
||||
|
||||
registerNodeSlots(nodeId, context)
|
||||
}
|
||||
|
||||
export function useSlotLayoutSync() {
|
||||
const unsubscribeLayoutChange = ref<() => void>()
|
||||
const restoreHandlers = ref<() => void>()
|
||||
|
||||
/**
|
||||
* Attempt to start slot layout sync with full event-driven functionality
|
||||
* @param canvas LiteGraph canvas instance
|
||||
* @returns true if sync was actually started, false if early-returned
|
||||
*/
|
||||
function attemptStart(canvas: LGraphCanvas): boolean {
|
||||
// When Vue nodes are enabled, slot DOM registers exact positions.
|
||||
// Skip calculated registration to avoid conflicts.
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
return false
|
||||
}
|
||||
const graph = canvas?.graph
|
||||
if (!graph) return false
|
||||
|
||||
// Initial registration for all nodes in the current graph
|
||||
for (const node of graph.nodes) {
|
||||
computeAndRegisterSlots(node)
|
||||
}
|
||||
|
||||
// Layout changes → recompute slots for changed nodes
|
||||
unsubscribeLayoutChange.value?.()
|
||||
unsubscribeLayoutChange.value = layoutStore.onChange((change) => {
|
||||
for (const nodeId of change.nodeIds) {
|
||||
const node = graph.getNodeById(parseInt(nodeId))
|
||||
if (node) {
|
||||
computeAndRegisterSlots(node)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// LiteGraph event hooks
|
||||
const origNodeAdded = graph.onNodeAdded
|
||||
const origNodeRemoved = graph.onNodeRemoved
|
||||
const origTrigger = graph.onTrigger
|
||||
const origAfterChange = graph.onAfterChange
|
||||
|
||||
graph.onNodeAdded = (node: LGraphNode) => {
|
||||
computeAndRegisterSlots(node)
|
||||
if (origNodeAdded) {
|
||||
origNodeAdded.call(graph, node)
|
||||
}
|
||||
}
|
||||
|
||||
graph.onNodeRemoved = (node: LGraphNode) => {
|
||||
layoutStore.deleteNodeSlotLayouts(String(node.id))
|
||||
if (origNodeRemoved) {
|
||||
origNodeRemoved.call(graph, node)
|
||||
}
|
||||
}
|
||||
|
||||
graph.onTrigger = (action: string, param: any) => {
|
||||
if (
|
||||
action === 'node:property:changed' &&
|
||||
param?.property === 'flags.collapsed'
|
||||
) {
|
||||
const node = graph.getNodeById(parseInt(String(param.nodeId)))
|
||||
if (node) {
|
||||
computeAndRegisterSlots(node)
|
||||
}
|
||||
}
|
||||
if (origTrigger) {
|
||||
origTrigger.call(graph, action, param)
|
||||
}
|
||||
}
|
||||
|
||||
graph.onAfterChange = (graph: any, node?: any) => {
|
||||
if (node && node.id) {
|
||||
computeAndRegisterSlots(node)
|
||||
}
|
||||
if (origAfterChange) {
|
||||
origAfterChange.call(graph, graph, node)
|
||||
}
|
||||
}
|
||||
|
||||
// Store cleanup function
|
||||
restoreHandlers.value = () => {
|
||||
graph.onNodeAdded = origNodeAdded || undefined
|
||||
graph.onNodeRemoved = origNodeRemoved || undefined
|
||||
// Only restore onTrigger if Vue nodes are not active
|
||||
// Vue node manager sets its own onTrigger handler
|
||||
if (!LiteGraph.vueNodesMode) {
|
||||
graph.onTrigger = origTrigger || undefined
|
||||
}
|
||||
graph.onAfterChange = origAfterChange || undefined
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
unsubscribeLayoutChange.value?.()
|
||||
unsubscribeLayoutChange.value = undefined
|
||||
restoreHandlers.value?.()
|
||||
restoreHandlers.value = undefined
|
||||
}
|
||||
|
||||
tryOnScopeDispose(stop)
|
||||
|
||||
return {
|
||||
attemptStart,
|
||||
stop
|
||||
}
|
||||
}
|
||||
79
src/renderer/core/layout/transform/TransformPane.vue
Normal file
79
src/renderer/core/layout/transform/TransformPane.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div
|
||||
data-testid="transform-pane"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 w-full h-full pointer-events-none',
|
||||
isInteracting ? 'transform-pane--interacting' : 'will-change-auto',
|
||||
isLOD && 'isLOD'
|
||||
)
|
||||
"
|
||||
:style="transformStyle"
|
||||
>
|
||||
<!-- Vue nodes will be rendered here -->
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
||||
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface TransformPaneProps {
|
||||
canvas?: LGraphCanvas
|
||||
}
|
||||
|
||||
const props = defineProps<TransformPaneProps>()
|
||||
|
||||
const {
|
||||
camera,
|
||||
transformStyle,
|
||||
syncWithCanvas,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
isNodeInViewport
|
||||
} = useTransformState()
|
||||
|
||||
const { isLOD } = useLOD(camera)
|
||||
|
||||
const canvasElement = computed(() => props.canvas?.canvas)
|
||||
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
|
||||
settleDelay: 200,
|
||||
trackPan: true
|
||||
})
|
||||
|
||||
provide(TransformStateKey, {
|
||||
camera,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
isNodeInViewport
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
transformUpdate: []
|
||||
}>()
|
||||
|
||||
useRafFn(
|
||||
() => {
|
||||
if (!props.canvas) {
|
||||
return
|
||||
}
|
||||
syncWithCanvas(props.canvas)
|
||||
emit('transformUpdate')
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.transform-pane--interacting {
|
||||
will-change: transform;
|
||||
}
|
||||
</style>
|
||||
151
src/renderer/core/layout/transform/useTransformSettling.ts
Normal file
151
src/renderer/core/layout/transform/useTransformSettling.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useDebounceFn, useEventListener, useThrottleFn } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
interface TransformSettlingOptions {
|
||||
/**
|
||||
* Delay in ms before transform is considered "settled" after last interaction
|
||||
* @default 200
|
||||
*/
|
||||
settleDelay?: number
|
||||
/**
|
||||
* Whether to track both zoom (wheel) and pan (pointer drag) interactions
|
||||
* @default false
|
||||
*/
|
||||
trackPan?: boolean
|
||||
/**
|
||||
* Throttle delay for high-frequency pointermove events (only used when trackPan is true)
|
||||
* @default 16 (~60fps)
|
||||
*/
|
||||
pointerMoveThrottle?: number
|
||||
/**
|
||||
* Whether to use passive event listeners (better performance but can't preventDefault)
|
||||
* @default true
|
||||
*/
|
||||
passive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks when canvas transforms (zoom/pan) are actively changing vs settled.
|
||||
*
|
||||
* This composable helps optimize rendering quality during transformations.
|
||||
* When the user is actively zooming or panning, we can reduce rendering quality
|
||||
* for better performance. Once the transform "settles" (stops changing), we can
|
||||
* trigger high-quality re-rasterization.
|
||||
*
|
||||
* The settling concept prevents constant quality switching during interactions
|
||||
* by waiting for a period of inactivity before considering the transform complete.
|
||||
*
|
||||
* Uses VueUse's useEventListener for automatic cleanup and useDebounceFn for
|
||||
* efficient settle detection.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { isTransforming } = useTransformSettling(canvasRef, {
|
||||
* settleDelay: 200,
|
||||
* trackPan: true
|
||||
* })
|
||||
*
|
||||
* // Use in CSS classes or rendering logic
|
||||
* const cssClass = computed(() => ({
|
||||
* 'low-quality': isTransforming.value,
|
||||
* 'high-quality': !isTransforming.value
|
||||
* }))
|
||||
* ```
|
||||
*/
|
||||
export function useTransformSettling(
|
||||
target: MaybeRefOrGetter<HTMLElement | null | undefined>,
|
||||
options: TransformSettlingOptions = {}
|
||||
) {
|
||||
const {
|
||||
settleDelay = 200,
|
||||
trackPan = false,
|
||||
pointerMoveThrottle = 16,
|
||||
passive = true
|
||||
} = options
|
||||
|
||||
const isTransforming = ref(false)
|
||||
let isPanning = false
|
||||
|
||||
/**
|
||||
* Mark transform as active
|
||||
*/
|
||||
const markTransformActive = () => {
|
||||
isTransforming.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark transform as settled (debounced)
|
||||
*/
|
||||
const markTransformSettled = useDebounceFn(() => {
|
||||
isTransforming.value = false
|
||||
}, settleDelay)
|
||||
|
||||
/**
|
||||
* Handle any transform event - mark active then queue settle
|
||||
*/
|
||||
const handleTransformEvent = () => {
|
||||
markTransformActive()
|
||||
void markTransformSettled()
|
||||
}
|
||||
|
||||
// Wheel handler
|
||||
const handleWheel = () => {
|
||||
handleTransformEvent()
|
||||
}
|
||||
|
||||
// Pointer handlers for panning
|
||||
const handlePointerDown = () => {
|
||||
if (trackPan) {
|
||||
isPanning = true
|
||||
handleTransformEvent()
|
||||
}
|
||||
}
|
||||
|
||||
// Throttled pointer move handler for performance
|
||||
const handlePointerMove = trackPan
|
||||
? useThrottleFn(() => {
|
||||
if (isPanning) {
|
||||
handleTransformEvent()
|
||||
}
|
||||
}, pointerMoveThrottle)
|
||||
: undefined
|
||||
|
||||
const handlePointerEnd = () => {
|
||||
if (trackPan) {
|
||||
isPanning = false
|
||||
// Don't immediately stop - let the debounced settle handle it
|
||||
}
|
||||
}
|
||||
|
||||
// Register event listeners with auto-cleanup
|
||||
useEventListener(target, 'wheel', handleWheel, {
|
||||
capture: true,
|
||||
passive
|
||||
})
|
||||
|
||||
if (trackPan) {
|
||||
useEventListener(target, 'pointerdown', handlePointerDown, {
|
||||
capture: true
|
||||
})
|
||||
|
||||
if (handlePointerMove) {
|
||||
useEventListener(target, 'pointermove', handlePointerMove, {
|
||||
capture: true,
|
||||
passive
|
||||
})
|
||||
}
|
||||
|
||||
useEventListener(target, 'pointerup', handlePointerEnd, {
|
||||
capture: true
|
||||
})
|
||||
|
||||
useEventListener(target, 'pointercancel', handlePointerEnd, {
|
||||
capture: true
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
isTransforming
|
||||
}
|
||||
}
|
||||
246
src/renderer/core/layout/transform/useTransformState.ts
Normal file
246
src/renderer/core/layout/transform/useTransformState.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Composable for managing transform state synchronized with LiteGraph canvas
|
||||
*
|
||||
* This composable is a critical part of the hybrid rendering architecture that
|
||||
* allows Vue components to render in perfect alignment with LiteGraph's canvas.
|
||||
*
|
||||
* ## Core Concept
|
||||
*
|
||||
* LiteGraph uses a canvas for rendering connections, grid, and handling interactions.
|
||||
* Vue components need to render nodes on top of this canvas. The challenge is
|
||||
* synchronizing the coordinate systems:
|
||||
*
|
||||
* - LiteGraph: Uses canvas coordinates with its own transform matrix
|
||||
* - Vue/DOM: Uses screen coordinates with CSS transforms
|
||||
*
|
||||
* ## Solution: Transform Container Pattern
|
||||
*
|
||||
* Instead of transforming individual nodes (O(n) complexity), we:
|
||||
* 1. Mirror LiteGraph's transform matrix to a single CSS container
|
||||
* 2. Place all Vue nodes as children with simple absolute positioning
|
||||
* 3. Achieve O(1) transform updates regardless of node count
|
||||
*
|
||||
* ## Coordinate Systems
|
||||
*
|
||||
* - **Canvas coordinates**: LiteGraph's internal coordinate system
|
||||
* - **Screen coordinates**: Browser's viewport coordinate system
|
||||
* - **Transform sync**: camera.x/y/z mirrors canvas.ds.offset/scale
|
||||
*
|
||||
* ## Performance Benefits
|
||||
*
|
||||
* - GPU acceleration via CSS transforms
|
||||
* - No layout thrashing (only transform changes)
|
||||
* - Efficient viewport culling calculations
|
||||
* - Scales to 1000+ nodes while maintaining 60 FPS
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { camera, transformStyle, canvasToScreen } = useTransformState()
|
||||
*
|
||||
* // In template
|
||||
* <div :style="transformStyle">
|
||||
* <NodeComponent
|
||||
* v-for="node in nodes"
|
||||
* :style="{ left: node.x + 'px', top: node.y + 'px' }"
|
||||
* />
|
||||
* </div>
|
||||
*
|
||||
* // Convert coordinates
|
||||
* const screenPos = canvasToScreen({ x: nodeX, y: nodeY })
|
||||
* ```
|
||||
*/
|
||||
import { computed, reactive, readonly } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface Camera {
|
||||
x: number
|
||||
y: number
|
||||
z: number // scale/zoom
|
||||
}
|
||||
|
||||
export const useTransformState = () => {
|
||||
// Reactive state mirroring LiteGraph's canvas transform
|
||||
const camera = reactive<Camera>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1
|
||||
})
|
||||
|
||||
// Computed transform string for CSS
|
||||
const transformStyle = computed(() => ({
|
||||
// Match LiteGraph DragAndScale.toCanvasContext():
|
||||
// ctx.scale(scale); ctx.translate(offset)
|
||||
// CSS applies right-to-left, so "scale() translate()" -> translate first, then scale
|
||||
// Effective mapping: screen = (canvas + offset) * scale
|
||||
transform: `scale(${camera.z}) translate(${camera.x}px, ${camera.y}px)`,
|
||||
transformOrigin: '0 0'
|
||||
}))
|
||||
|
||||
/**
|
||||
* Synchronizes Vue's reactive camera state with LiteGraph's canvas transform
|
||||
*
|
||||
* Called every frame via RAF to ensure Vue components stay aligned with canvas.
|
||||
* This is the heart of the hybrid rendering system - it bridges the gap between
|
||||
* LiteGraph's canvas transforms and Vue's reactive system.
|
||||
*
|
||||
* @param canvas - LiteGraph canvas instance with DragAndScale (ds) transform state
|
||||
*/
|
||||
const syncWithCanvas = (canvas: LGraphCanvas) => {
|
||||
if (!canvas || !canvas.ds) return
|
||||
|
||||
// Mirror LiteGraph's transform state to Vue's reactive state
|
||||
// ds.offset = pan offset, ds.scale = zoom level
|
||||
camera.x = canvas.ds.offset[0]
|
||||
camera.y = canvas.ds.offset[1]
|
||||
camera.z = canvas.ds.scale || 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts canvas coordinates to screen coordinates
|
||||
*
|
||||
* Applies the same transform that LiteGraph uses for rendering.
|
||||
* Essential for positioning Vue components to align with canvas elements.
|
||||
*
|
||||
* Formula: screen = (canvas + offset) * scale
|
||||
*
|
||||
* @param point - Point in canvas coordinate system
|
||||
* @returns Point in screen coordinate system
|
||||
*/
|
||||
const canvasToScreen = (point: Point): Point => {
|
||||
return {
|
||||
x: (point.x + camera.x) * camera.z,
|
||||
y: (point.y + camera.y) * camera.z
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts screen coordinates to canvas coordinates
|
||||
*
|
||||
* Inverse of canvasToScreen. Useful for hit testing and converting
|
||||
* mouse events back to canvas space.
|
||||
*
|
||||
* Formula: canvas = screen / scale - offset
|
||||
*
|
||||
* @param point - Point in screen coordinate system
|
||||
* @returns Point in canvas coordinate system
|
||||
*/
|
||||
const screenToCanvas = (point: Point): Point => {
|
||||
return {
|
||||
x: point.x / camera.z - camera.x,
|
||||
y: point.y / camera.z - camera.y
|
||||
}
|
||||
}
|
||||
|
||||
// Get node's screen bounds for culling
|
||||
const getNodeScreenBounds = (
|
||||
pos: ArrayLike<number>,
|
||||
size: ArrayLike<number>
|
||||
): DOMRect => {
|
||||
const topLeft = canvasToScreen({ x: pos[0], y: pos[1] })
|
||||
const width = size[0] * camera.z
|
||||
const height = size[1] * camera.z
|
||||
|
||||
return new DOMRect(topLeft.x, topLeft.y, width, height)
|
||||
}
|
||||
|
||||
// Helper: Calculate zoom-adjusted margin for viewport culling
|
||||
const calculateAdjustedMargin = (baseMargin: number): number => {
|
||||
if (camera.z < 0.1) return Math.min(baseMargin * 5, 2.0)
|
||||
if (camera.z > 3.0) return Math.max(baseMargin * 0.5, 0.05)
|
||||
return baseMargin
|
||||
}
|
||||
|
||||
// Helper: Check if node is too small to be visible at current zoom
|
||||
const isNodeTooSmall = (nodeSize: ArrayLike<number>): boolean => {
|
||||
const nodeScreenSize = Math.max(nodeSize[0], nodeSize[1]) * camera.z
|
||||
return nodeScreenSize < 4
|
||||
}
|
||||
|
||||
// Helper: Calculate expanded viewport bounds with margin
|
||||
const getExpandedViewportBounds = (
|
||||
viewport: { width: number; height: number },
|
||||
margin: number
|
||||
) => {
|
||||
const marginX = viewport.width * margin
|
||||
const marginY = viewport.height * margin
|
||||
return {
|
||||
left: -marginX,
|
||||
right: viewport.width + marginX,
|
||||
top: -marginY,
|
||||
bottom: viewport.height + marginY
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Test if node intersects with viewport bounds
|
||||
const testViewportIntersection = (
|
||||
screenPos: { x: number; y: number },
|
||||
nodeSize: ArrayLike<number>,
|
||||
bounds: { left: number; right: number; top: number; bottom: number }
|
||||
): boolean => {
|
||||
const nodeRight = screenPos.x + nodeSize[0] * camera.z
|
||||
const nodeBottom = screenPos.y + nodeSize[1] * camera.z
|
||||
|
||||
return !(
|
||||
nodeRight < bounds.left ||
|
||||
screenPos.x > bounds.right ||
|
||||
nodeBottom < bounds.top ||
|
||||
screenPos.y > bounds.bottom
|
||||
)
|
||||
}
|
||||
|
||||
// Check if node is within viewport with frustum and size-based culling
|
||||
const isNodeInViewport = (
|
||||
nodePos: ArrayLike<number>,
|
||||
nodeSize: ArrayLike<number>,
|
||||
viewport: { width: number; height: number },
|
||||
margin: number = 0.2
|
||||
): boolean => {
|
||||
// Early exit for tiny nodes
|
||||
if (isNodeTooSmall(nodeSize)) return false
|
||||
|
||||
const screenPos = canvasToScreen({ x: nodePos[0], y: nodePos[1] })
|
||||
const adjustedMargin = calculateAdjustedMargin(margin)
|
||||
const bounds = getExpandedViewportBounds(viewport, adjustedMargin)
|
||||
|
||||
return testViewportIntersection(screenPos, nodeSize, bounds)
|
||||
}
|
||||
|
||||
// Get viewport bounds in canvas coordinates (for spatial index queries)
|
||||
const getViewportBounds = (
|
||||
viewport: { width: number; height: number },
|
||||
margin: number = 0.2
|
||||
) => {
|
||||
const marginX = viewport.width * margin
|
||||
const marginY = viewport.height * margin
|
||||
|
||||
const topLeft = screenToCanvas({ x: -marginX, y: -marginY })
|
||||
const bottomRight = screenToCanvas({
|
||||
x: viewport.width + marginX,
|
||||
y: viewport.height + marginY
|
||||
})
|
||||
|
||||
return {
|
||||
x: topLeft.x,
|
||||
y: topLeft.y,
|
||||
width: bottomRight.x - topLeft.x,
|
||||
height: bottomRight.y - topLeft.y
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
camera: readonly(camera),
|
||||
transformStyle,
|
||||
syncWithCanvas,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
getNodeScreenBounds,
|
||||
isNodeInViewport,
|
||||
getViewportBounds
|
||||
}
|
||||
}
|
||||
337
src/renderer/core/layout/types.ts
Normal file
337
src/renderer/core/layout/types.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Layout System - Type Definitions
|
||||
*
|
||||
* This file contains all type definitions for the layout system
|
||||
* that manages node positions, bounds, spatial data, and operations.
|
||||
*/
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
// Enum for layout source types
|
||||
export enum LayoutSource {
|
||||
Canvas = 'canvas',
|
||||
Vue = 'vue',
|
||||
External = 'external'
|
||||
}
|
||||
|
||||
// Basic geometric types
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface Bounds {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface NodeBoundsUpdate {
|
||||
nodeId: NodeId
|
||||
bounds: Bounds
|
||||
}
|
||||
|
||||
export type NodeId = string
|
||||
export type LinkId = number
|
||||
export type RerouteId = number
|
||||
|
||||
// Layout data structures
|
||||
export interface NodeLayout {
|
||||
id: NodeId
|
||||
position: Point
|
||||
size: Size
|
||||
zIndex: number
|
||||
visible: boolean
|
||||
// Computed bounds for hit testing
|
||||
bounds: Bounds
|
||||
}
|
||||
|
||||
export interface SlotLayout {
|
||||
nodeId: NodeId
|
||||
index: number
|
||||
type: 'input' | 'output'
|
||||
position: Point
|
||||
bounds: Bounds
|
||||
}
|
||||
|
||||
export interface LinkLayout {
|
||||
id: LinkId
|
||||
path: Path2D
|
||||
bounds: Bounds
|
||||
centerPos: Point
|
||||
sourceNodeId: NodeId
|
||||
targetNodeId: NodeId
|
||||
sourceSlot: number
|
||||
targetSlot: number
|
||||
}
|
||||
|
||||
// Layout for individual link segments (for precise hit-testing)
|
||||
export interface LinkSegmentLayout {
|
||||
linkId: LinkId
|
||||
rerouteId: RerouteId | null // null for final segment to target
|
||||
path: Path2D
|
||||
bounds: Bounds
|
||||
centerPos: Point
|
||||
}
|
||||
|
||||
export interface RerouteLayout {
|
||||
id: RerouteId
|
||||
position: Point
|
||||
radius: number
|
||||
bounds: Bounds
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta-only base for all operations - contains common fields
|
||||
*/
|
||||
interface OperationMeta {
|
||||
/** Unique operation ID for deduplication */
|
||||
id?: string
|
||||
/** Timestamp for ordering operations */
|
||||
timestamp: number
|
||||
/** Actor who performed the operation (for CRDT) */
|
||||
actor: string
|
||||
/** Source system that initiated the operation */
|
||||
source: LayoutSource
|
||||
/** Operation type discriminator */
|
||||
type: OperationType
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity-specific base types for proper type discrimination
|
||||
*/
|
||||
type NodeOpBase = OperationMeta & { entity: 'node'; nodeId: NodeId }
|
||||
type LinkOpBase = OperationMeta & { entity: 'link'; linkId: LinkId }
|
||||
type RerouteOpBase = OperationMeta & {
|
||||
entity: 'reroute'
|
||||
rerouteId: RerouteId
|
||||
}
|
||||
|
||||
/**
|
||||
* Operation type discriminator for type narrowing
|
||||
*/
|
||||
type OperationType =
|
||||
| 'moveNode'
|
||||
| 'resizeNode'
|
||||
| 'setNodeZIndex'
|
||||
| 'createNode'
|
||||
| 'deleteNode'
|
||||
| 'setNodeVisibility'
|
||||
| 'batchUpdate'
|
||||
| 'createLink'
|
||||
| 'deleteLink'
|
||||
| 'createReroute'
|
||||
| 'deleteReroute'
|
||||
| 'moveReroute'
|
||||
|
||||
/**
|
||||
* Move node operation
|
||||
*/
|
||||
export interface MoveNodeOperation extends NodeOpBase {
|
||||
type: 'moveNode'
|
||||
position: Point
|
||||
previousPosition: Point
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize node operation
|
||||
*/
|
||||
export interface ResizeNodeOperation extends NodeOpBase {
|
||||
type: 'resizeNode'
|
||||
size: { width: number; height: number }
|
||||
previousSize: { width: number; height: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set node z-index operation
|
||||
*/
|
||||
export interface SetNodeZIndexOperation extends NodeOpBase {
|
||||
type: 'setNodeZIndex'
|
||||
zIndex: number
|
||||
previousZIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Create node operation
|
||||
*/
|
||||
export interface CreateNodeOperation extends NodeOpBase {
|
||||
type: 'createNode'
|
||||
layout: NodeLayout
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete node operation
|
||||
*/
|
||||
export interface DeleteNodeOperation extends NodeOpBase {
|
||||
type: 'deleteNode'
|
||||
previousLayout: NodeLayout
|
||||
}
|
||||
|
||||
/**
|
||||
* Set node visibility operation
|
||||
*/
|
||||
interface SetNodeVisibilityOperation extends NodeOpBase {
|
||||
type: 'setNodeVisibility'
|
||||
visible: boolean
|
||||
previousVisible: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update operation for atomic multi-property changes
|
||||
*/
|
||||
interface BatchUpdateOperation extends NodeOpBase {
|
||||
type: 'batchUpdate'
|
||||
updates: Partial<NodeLayout>
|
||||
previousValues: Partial<NodeLayout>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create link operation
|
||||
*/
|
||||
export interface CreateLinkOperation extends LinkOpBase {
|
||||
type: 'createLink'
|
||||
sourceNodeId: NodeId
|
||||
sourceSlot: number
|
||||
targetNodeId: NodeId
|
||||
targetSlot: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete link operation
|
||||
*/
|
||||
export interface DeleteLinkOperation extends LinkOpBase {
|
||||
type: 'deleteLink'
|
||||
}
|
||||
|
||||
/**
|
||||
* Create reroute operation
|
||||
*/
|
||||
export interface CreateRerouteOperation extends RerouteOpBase {
|
||||
type: 'createReroute'
|
||||
position: Point
|
||||
parentId?: RerouteId
|
||||
linkIds: LinkId[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete reroute operation
|
||||
*/
|
||||
export interface DeleteRerouteOperation extends RerouteOpBase {
|
||||
type: 'deleteReroute'
|
||||
}
|
||||
|
||||
/**
|
||||
* Move reroute operation
|
||||
*/
|
||||
export interface MoveRerouteOperation extends RerouteOpBase {
|
||||
type: 'moveReroute'
|
||||
position: Point
|
||||
previousPosition: Point
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of all operation types
|
||||
*/
|
||||
export type LayoutOperation =
|
||||
| MoveNodeOperation
|
||||
| ResizeNodeOperation
|
||||
| SetNodeZIndexOperation
|
||||
| CreateNodeOperation
|
||||
| DeleteNodeOperation
|
||||
| SetNodeVisibilityOperation
|
||||
| BatchUpdateOperation
|
||||
| CreateLinkOperation
|
||||
| DeleteLinkOperation
|
||||
| CreateRerouteOperation
|
||||
| DeleteRerouteOperation
|
||||
| MoveRerouteOperation
|
||||
|
||||
export interface LayoutChange {
|
||||
type: 'create' | 'update' | 'delete'
|
||||
nodeIds: NodeId[]
|
||||
timestamp: number
|
||||
source: LayoutSource
|
||||
operation: LayoutOperation
|
||||
}
|
||||
|
||||
// Store interfaces
|
||||
export interface LayoutStore {
|
||||
// CustomRef accessors for shared write access
|
||||
getNodeLayoutRef(nodeId: NodeId): Ref<NodeLayout | null>
|
||||
getNodesInBounds(bounds: Bounds): ComputedRef<NodeId[]>
|
||||
getAllNodes(): ComputedRef<ReadonlyMap<NodeId, NodeLayout>>
|
||||
getVersion(): ComputedRef<number>
|
||||
|
||||
// Spatial queries (non-reactive)
|
||||
queryNodeAtPoint(point: Point): NodeId | null
|
||||
queryNodesInBounds(bounds: Bounds): NodeId[]
|
||||
|
||||
// Hit testing queries for links, slots, and reroutes
|
||||
queryLinkAtPoint(point: Point, ctx?: CanvasRenderingContext2D): LinkId | null
|
||||
queryLinkSegmentAtPoint(
|
||||
point: Point,
|
||||
ctx?: CanvasRenderingContext2D
|
||||
): { linkId: LinkId; rerouteId: RerouteId | null } | null
|
||||
querySlotAtPoint(point: Point): SlotLayout | null
|
||||
queryRerouteAtPoint(point: Point): RerouteLayout | null
|
||||
queryItemsInBounds(bounds: Bounds): {
|
||||
nodes: NodeId[]
|
||||
links: LinkId[]
|
||||
slots: string[]
|
||||
reroutes: RerouteId[]
|
||||
}
|
||||
|
||||
// Update methods for link, slot, and reroute layouts
|
||||
updateLinkLayout(linkId: LinkId, layout: LinkLayout): void
|
||||
updateLinkSegmentLayout(
|
||||
linkId: LinkId,
|
||||
rerouteId: RerouteId | null,
|
||||
layout: Omit<LinkSegmentLayout, 'linkId' | 'rerouteId'>
|
||||
): void
|
||||
updateSlotLayout(key: string, layout: SlotLayout): void
|
||||
updateRerouteLayout(rerouteId: RerouteId, layout: RerouteLayout): void
|
||||
|
||||
// Delete methods for cleanup
|
||||
deleteLinkLayout(linkId: LinkId): void
|
||||
deleteLinkSegmentLayout(linkId: LinkId, rerouteId: RerouteId | null): void
|
||||
deleteSlotLayout(key: string): void
|
||||
deleteNodeSlotLayouts(nodeId: NodeId): void
|
||||
deleteRerouteLayout(rerouteId: RerouteId): void
|
||||
clearAllSlotLayouts(): void
|
||||
|
||||
// Get layout data
|
||||
getLinkLayout(linkId: LinkId): LinkLayout | null
|
||||
getSlotLayout(key: string): SlotLayout | null
|
||||
getRerouteLayout(rerouteId: RerouteId): RerouteLayout | null
|
||||
|
||||
// Direct mutation API (CRDT-ready)
|
||||
applyOperation(operation: LayoutOperation): void
|
||||
|
||||
// Change subscription
|
||||
onChange(callback: (change: LayoutChange) => void): () => void
|
||||
|
||||
// Initialization
|
||||
initializeFromLiteGraph(
|
||||
nodes: Array<{ id: string; pos: [number, number]; size: [number, number] }>
|
||||
): void
|
||||
|
||||
// Source and actor management
|
||||
setSource(source: LayoutSource): void
|
||||
setActor(actor: string): void
|
||||
getCurrentSource(): LayoutSource
|
||||
getCurrentActor(): string
|
||||
|
||||
// Batch updates
|
||||
batchUpdateNodeBounds(
|
||||
updates: Array<{ nodeId: NodeId; bounds: Bounds }>
|
||||
): void
|
||||
|
||||
batchUpdateSlotLayouts(
|
||||
updates: Array<{ key: string; layout: SlotLayout }>
|
||||
): void
|
||||
}
|
||||
15
src/renderer/core/layout/utils/geometry.ts
Normal file
15
src/renderer/core/layout/utils/geometry.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Bounds, Point, Size } from '@/renderer/core/layout/types'
|
||||
|
||||
export function isPointEqual(a: Point, b: Point): boolean {
|
||||
return a.x === b.x && a.y === b.y
|
||||
}
|
||||
|
||||
export function isBoundsEqual(a: Bounds, b: Bounds): boolean {
|
||||
return (
|
||||
a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
|
||||
)
|
||||
}
|
||||
|
||||
export function isSizeEqual(a: Size, b: Size): boolean {
|
||||
return a.width === b.width && a.height === b.height
|
||||
}
|
||||
53
src/renderer/core/layout/utils/layoutMath.ts
Normal file
53
src/renderer/core/layout/utils/layoutMath.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { Bounds, NodeLayout, Point } from '@/renderer/core/layout/types'
|
||||
|
||||
export const REROUTE_RADIUS = 8
|
||||
|
||||
export function pointInBounds(point: Point, bounds: Bounds): boolean {
|
||||
return (
|
||||
point.x >= bounds.x &&
|
||||
point.x <= bounds.x + bounds.width &&
|
||||
point.y >= bounds.y &&
|
||||
point.y <= bounds.y + bounds.height
|
||||
)
|
||||
}
|
||||
|
||||
export function boundsIntersect(a: Bounds, b: Bounds): boolean {
|
||||
return !(
|
||||
a.x + a.width < b.x ||
|
||||
b.x + b.width < a.x ||
|
||||
a.y + a.height < b.y ||
|
||||
b.y + b.height < a.y
|
||||
)
|
||||
}
|
||||
|
||||
export function calculateBounds(nodes: NodeLayout[]): Bounds {
|
||||
let minX = Infinity,
|
||||
minY = Infinity
|
||||
let maxX = -Infinity,
|
||||
maxY = -Infinity
|
||||
|
||||
for (const node of nodes) {
|
||||
const bounds = node.bounds
|
||||
minX = Math.min(minX, bounds.x)
|
||||
minY = Math.min(minY, bounds.y)
|
||||
maxX = Math.max(maxX, bounds.x + bounds.width)
|
||||
maxY = Math.max(maxY, bounds.y + bounds.height)
|
||||
}
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate combined bounds for Vue nodes selection
|
||||
* @param nodes Array of NodeLayout objects to calculate bounds for
|
||||
* @returns Bounds of the nodes or null if no nodes provided
|
||||
*/
|
||||
export function selectionBounds(nodes: NodeLayout[]): Bounds | null {
|
||||
if (nodes.length === 0) return null
|
||||
return calculateBounds(nodes)
|
||||
}
|
||||
11
src/renderer/core/layout/utils/layoutUtils.ts
Normal file
11
src/renderer/core/layout/utils/layoutUtils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { LinkId, RerouteId } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Creates a unique key for identifying link segments in spatial indexes
|
||||
*/
|
||||
export function makeLinkSegmentKey(
|
||||
linkId: LinkId,
|
||||
rerouteId: RerouteId | null
|
||||
): string {
|
||||
return `${linkId}:${rerouteId ?? 'final'}`
|
||||
}
|
||||
45
src/renderer/core/layout/utils/mappers.ts
Normal file
45
src/renderer/core/layout/utils/mappers.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import type { NodeLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
export type NodeLayoutMap = Y.Map<NodeLayout[keyof NodeLayout]>
|
||||
|
||||
export const NODE_LAYOUT_DEFAULTS: NodeLayout = {
|
||||
id: 'unknown-node',
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 50 },
|
||||
zIndex: 0,
|
||||
visible: true,
|
||||
bounds: { x: 0, y: 0, width: 100, height: 50 }
|
||||
}
|
||||
|
||||
export function layoutToYNode(layout: NodeLayout): NodeLayoutMap {
|
||||
const ynode = new Y.Map<NodeLayout[keyof NodeLayout]>() as NodeLayoutMap
|
||||
ynode.set('id', layout.id)
|
||||
ynode.set('position', layout.position)
|
||||
ynode.set('size', layout.size)
|
||||
ynode.set('zIndex', layout.zIndex)
|
||||
ynode.set('visible', layout.visible)
|
||||
ynode.set('bounds', layout.bounds)
|
||||
return ynode
|
||||
}
|
||||
|
||||
function getOr<K extends keyof NodeLayout>(
|
||||
map: NodeLayoutMap,
|
||||
key: K,
|
||||
fallback: NodeLayout[K]
|
||||
): NodeLayout[K] {
|
||||
const v = map.get(key)
|
||||
return (v ?? fallback) as NodeLayout[K]
|
||||
}
|
||||
|
||||
export function yNodeToLayout(ynode: NodeLayoutMap): NodeLayout {
|
||||
return {
|
||||
id: getOr(ynode, 'id', NODE_LAYOUT_DEFAULTS.id),
|
||||
position: getOr(ynode, 'position', NODE_LAYOUT_DEFAULTS.position),
|
||||
size: getOr(ynode, 'size', NODE_LAYOUT_DEFAULTS.size),
|
||||
zIndex: getOr(ynode, 'zIndex', NODE_LAYOUT_DEFAULTS.zIndex),
|
||||
visible: getOr(ynode, 'visible', NODE_LAYOUT_DEFAULTS.visible),
|
||||
bounds: getOr(ynode, 'bounds', NODE_LAYOUT_DEFAULTS.bounds)
|
||||
}
|
||||
}
|
||||
302
src/renderer/core/spatial/QuadTree.ts
Normal file
302
src/renderer/core/spatial/QuadTree.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* QuadTree implementation for spatial indexing of nodes
|
||||
* Optimized for viewport culling in large node graphs
|
||||
*/
|
||||
import type {
|
||||
QuadNodeDebugInfo,
|
||||
SpatialIndexDebugInfo
|
||||
} from '@/types/spatialIndex'
|
||||
|
||||
export interface Bounds {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
interface QuadTreeItem<T> {
|
||||
id: string
|
||||
bounds: Bounds
|
||||
data: T
|
||||
}
|
||||
|
||||
interface QuadTreeOptions {
|
||||
maxDepth?: number
|
||||
maxItemsPerNode?: number
|
||||
minNodeSize?: number
|
||||
}
|
||||
|
||||
class QuadNode<T> {
|
||||
private bounds: Bounds
|
||||
private depth: number
|
||||
private maxDepth: number
|
||||
private maxItems: number
|
||||
private items: QuadTreeItem<T>[] = []
|
||||
private children: QuadNode<T>[] | null = null
|
||||
private divided = false
|
||||
|
||||
constructor(
|
||||
bounds: Bounds,
|
||||
depth: number = 0,
|
||||
maxDepth: number = 5,
|
||||
maxItems: number = 4
|
||||
) {
|
||||
this.bounds = bounds
|
||||
this.depth = depth
|
||||
this.maxDepth = maxDepth
|
||||
this.maxItems = maxItems
|
||||
}
|
||||
|
||||
insert(item: QuadTreeItem<T>): boolean {
|
||||
// Check if item is within bounds
|
||||
if (!this.contains(item.bounds)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If we have space and haven't divided, add to this node
|
||||
if (this.items.length < this.maxItems && !this.divided) {
|
||||
this.items.push(item)
|
||||
return true
|
||||
}
|
||||
|
||||
// If we haven't reached max depth, subdivide
|
||||
if (!this.divided && this.depth < this.maxDepth) {
|
||||
this.subdivide()
|
||||
}
|
||||
|
||||
// If divided, insert into children
|
||||
if (this.divided && this.children) {
|
||||
for (const child of this.children) {
|
||||
if (child.insert(item)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we can't subdivide further, add to this node anyway
|
||||
this.items.push(item)
|
||||
return true
|
||||
}
|
||||
|
||||
remove(item: QuadTreeItem<T>): boolean {
|
||||
const index = this.items.findIndex((i) => i.id === item.id)
|
||||
if (index !== -1) {
|
||||
this.items.splice(index, 1)
|
||||
return true
|
||||
}
|
||||
|
||||
if (this.divided && this.children) {
|
||||
for (const child of this.children) {
|
||||
if (child.remove(item)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
query(
|
||||
searchBounds: Bounds,
|
||||
found: QuadTreeItem<T>[] = []
|
||||
): QuadTreeItem<T>[] {
|
||||
// Check if search area intersects with this node
|
||||
if (!this.intersects(searchBounds)) {
|
||||
return found
|
||||
}
|
||||
|
||||
// Add items in this node that intersect with search bounds
|
||||
for (const item of this.items) {
|
||||
if (this.boundsIntersect(item.bounds, searchBounds)) {
|
||||
found.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively search children
|
||||
if (this.divided && this.children) {
|
||||
for (const child of this.children) {
|
||||
child.query(searchBounds, found)
|
||||
}
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
private subdivide() {
|
||||
const { x, y, width, height } = this.bounds
|
||||
const halfWidth = width / 2
|
||||
const halfHeight = height / 2
|
||||
|
||||
this.children = [
|
||||
// Top-left
|
||||
new QuadNode<T>(
|
||||
{ x, y, width: halfWidth, height: halfHeight },
|
||||
this.depth + 1,
|
||||
this.maxDepth,
|
||||
this.maxItems
|
||||
),
|
||||
// Top-right
|
||||
new QuadNode<T>(
|
||||
{ x: x + halfWidth, y, width: halfWidth, height: halfHeight },
|
||||
this.depth + 1,
|
||||
this.maxDepth,
|
||||
this.maxItems
|
||||
),
|
||||
// Bottom-left
|
||||
new QuadNode<T>(
|
||||
{ x, y: y + halfHeight, width: halfWidth, height: halfHeight },
|
||||
this.depth + 1,
|
||||
this.maxDepth,
|
||||
this.maxItems
|
||||
),
|
||||
// Bottom-right
|
||||
new QuadNode<T>(
|
||||
{
|
||||
x: x + halfWidth,
|
||||
y: y + halfHeight,
|
||||
width: halfWidth,
|
||||
height: halfHeight
|
||||
},
|
||||
this.depth + 1,
|
||||
this.maxDepth,
|
||||
this.maxItems
|
||||
)
|
||||
]
|
||||
|
||||
this.divided = true
|
||||
|
||||
// Redistribute existing items to children
|
||||
const itemsToRedistribute = [...this.items]
|
||||
this.items = []
|
||||
|
||||
for (const item of itemsToRedistribute) {
|
||||
let inserted = false
|
||||
for (const child of this.children) {
|
||||
if (child.insert(item)) {
|
||||
inserted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// Keep in parent if it doesn't fit in any child
|
||||
if (!inserted) {
|
||||
this.items.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private contains(itemBounds: Bounds): boolean {
|
||||
return (
|
||||
itemBounds.x >= this.bounds.x &&
|
||||
itemBounds.y >= this.bounds.y &&
|
||||
itemBounds.x + itemBounds.width <= this.bounds.x + this.bounds.width &&
|
||||
itemBounds.y + itemBounds.height <= this.bounds.y + this.bounds.height
|
||||
)
|
||||
}
|
||||
|
||||
private intersects(searchBounds: Bounds): boolean {
|
||||
return this.boundsIntersect(this.bounds, searchBounds)
|
||||
}
|
||||
|
||||
private boundsIntersect(a: Bounds, b: Bounds): boolean {
|
||||
return !(
|
||||
a.x + a.width < b.x ||
|
||||
b.x + b.width < a.x ||
|
||||
a.y + a.height < b.y ||
|
||||
b.y + b.height < a.y
|
||||
)
|
||||
}
|
||||
|
||||
// Debug helper to get tree structure
|
||||
getDebugInfo(): QuadNodeDebugInfo {
|
||||
return {
|
||||
bounds: this.bounds,
|
||||
depth: this.depth,
|
||||
itemCount: this.items.length,
|
||||
divided: this.divided,
|
||||
children: this.children?.map((child) => child.getDebugInfo())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class QuadTree<T> {
|
||||
private root: QuadNode<T>
|
||||
private itemMap: Map<string, QuadTreeItem<T>> = new Map()
|
||||
private options: Required<QuadTreeOptions>
|
||||
|
||||
constructor(bounds: Bounds, options: QuadTreeOptions = {}) {
|
||||
this.options = {
|
||||
maxDepth: options.maxDepth ?? 5,
|
||||
maxItemsPerNode: options.maxItemsPerNode ?? 4,
|
||||
minNodeSize: options.minNodeSize ?? 50
|
||||
}
|
||||
|
||||
this.root = new QuadNode<T>(
|
||||
bounds,
|
||||
0,
|
||||
this.options.maxDepth,
|
||||
this.options.maxItemsPerNode
|
||||
)
|
||||
}
|
||||
|
||||
insert(id: string, bounds: Bounds, data: T): boolean {
|
||||
const item: QuadTreeItem<T> = { id, bounds, data }
|
||||
|
||||
// Remove old item if it exists
|
||||
if (this.itemMap.has(id)) {
|
||||
this.remove(id)
|
||||
}
|
||||
|
||||
const success = this.root.insert(item)
|
||||
if (success) {
|
||||
this.itemMap.set(id, item)
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
remove(id: string): boolean {
|
||||
const item = this.itemMap.get(id)
|
||||
if (!item) return false
|
||||
|
||||
const success = this.root.remove(item)
|
||||
if (success) {
|
||||
this.itemMap.delete(id)
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
update(id: string, newBounds: Bounds): boolean {
|
||||
const item = this.itemMap.get(id)
|
||||
if (!item) return false
|
||||
|
||||
// Remove and re-insert with new bounds
|
||||
const data = item.data
|
||||
this.remove(id)
|
||||
return this.insert(id, newBounds, data)
|
||||
}
|
||||
|
||||
query(searchBounds: Bounds): T[] {
|
||||
const items = this.root.query(searchBounds)
|
||||
return items.map((item) => item.data)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.root = new QuadNode<T>(
|
||||
this.root['bounds'],
|
||||
0,
|
||||
this.options.maxDepth,
|
||||
this.options.maxItemsPerNode
|
||||
)
|
||||
this.itemMap.clear()
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.itemMap.size
|
||||
}
|
||||
|
||||
getDebugInfo(): SpatialIndexDebugInfo {
|
||||
return {
|
||||
size: this.size,
|
||||
tree: this.root.getDebugInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
170
src/renderer/core/spatial/SpatialIndex.ts
Normal file
170
src/renderer/core/spatial/SpatialIndex.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Spatial Index Manager
|
||||
*
|
||||
* Manages spatial indexing for efficient node queries based on bounds.
|
||||
* Uses QuadTree for fast spatial lookups with caching for performance.
|
||||
*/
|
||||
import {
|
||||
PERFORMANCE_CONFIG,
|
||||
QUADTREE_CONFIG
|
||||
} from '@/renderer/core/layout/constants'
|
||||
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
|
||||
|
||||
import { QuadTree } from './QuadTree'
|
||||
|
||||
/**
|
||||
* Cache entry for spatial queries
|
||||
*/
|
||||
interface CacheEntry {
|
||||
result: NodeId[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Spatial index manager using QuadTree
|
||||
*/
|
||||
export class SpatialIndexManager {
|
||||
private quadTree: QuadTree<NodeId>
|
||||
private queryCache: Map<string, CacheEntry>
|
||||
private cacheSize = 0
|
||||
|
||||
constructor(bounds?: Bounds) {
|
||||
this.quadTree = new QuadTree<NodeId>(
|
||||
bounds ?? QUADTREE_CONFIG.DEFAULT_BOUNDS,
|
||||
{
|
||||
maxDepth: QUADTREE_CONFIG.MAX_DEPTH,
|
||||
maxItemsPerNode: QUADTREE_CONFIG.MAX_ITEMS_PER_NODE
|
||||
}
|
||||
)
|
||||
this.queryCache = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a node into the spatial index
|
||||
*/
|
||||
insert(nodeId: NodeId, bounds: Bounds): void {
|
||||
this.quadTree.insert(nodeId, bounds, nodeId)
|
||||
this.invalidateCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a node's bounds in the spatial index
|
||||
*/
|
||||
update(nodeId: NodeId, bounds: Bounds): void {
|
||||
this.quadTree.update(nodeId, bounds)
|
||||
this.invalidateCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a node from the spatial index
|
||||
*/
|
||||
remove(nodeId: NodeId): void {
|
||||
this.quadTree.remove(nodeId)
|
||||
this.invalidateCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* Query nodes within the given bounds
|
||||
*/
|
||||
query(bounds: Bounds): NodeId[] {
|
||||
const cacheKey = this.getCacheKey(bounds)
|
||||
const cached = this.queryCache.get(cacheKey)
|
||||
|
||||
// Check cache validity
|
||||
if (cached) {
|
||||
const age = Date.now() - cached.timestamp
|
||||
if (age < PERFORMANCE_CONFIG.SPATIAL_CACHE_TTL) {
|
||||
return cached.result
|
||||
}
|
||||
// Remove stale entry
|
||||
this.queryCache.delete(cacheKey)
|
||||
this.cacheSize--
|
||||
}
|
||||
|
||||
// Perform query
|
||||
const result = this.quadTree.query(bounds)
|
||||
|
||||
// Cache result
|
||||
this.addToCache(cacheKey, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all nodes from the spatial index
|
||||
*/
|
||||
clear(): void {
|
||||
this.quadTree.clear()
|
||||
this.invalidateCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current size of the index
|
||||
*/
|
||||
get size(): number {
|
||||
return this.quadTree.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Get debug information about the spatial index
|
||||
*/
|
||||
getDebugInfo() {
|
||||
return {
|
||||
quadTreeInfo: this.quadTree.getDebugInfo(),
|
||||
cacheSize: this.cacheSize,
|
||||
cacheEntries: this.queryCache.size
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for bounds
|
||||
*/
|
||||
private getCacheKey(bounds: Bounds): string {
|
||||
return `${bounds.x},${bounds.y},${bounds.width},${bounds.height}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Add result to cache with LRU eviction
|
||||
*/
|
||||
private addToCache(key: string, result: NodeId[]): void {
|
||||
// Evict oldest entries if cache is full
|
||||
if (this.cacheSize >= PERFORMANCE_CONFIG.SPATIAL_CACHE_MAX_SIZE) {
|
||||
const oldestKey = this.findOldestCacheEntry()
|
||||
if (oldestKey) {
|
||||
this.queryCache.delete(oldestKey)
|
||||
this.cacheSize--
|
||||
}
|
||||
}
|
||||
|
||||
this.queryCache.set(key, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
this.cacheSize++
|
||||
}
|
||||
|
||||
/**
|
||||
* Find oldest cache entry for LRU eviction
|
||||
*/
|
||||
private findOldestCacheEntry(): string | null {
|
||||
let oldestKey: string | null = null
|
||||
let oldestTime = Infinity
|
||||
|
||||
for (const [key, entry] of this.queryCache) {
|
||||
if (entry.timestamp < oldestTime) {
|
||||
oldestTime = entry.timestamp
|
||||
oldestKey = key
|
||||
}
|
||||
}
|
||||
|
||||
return oldestKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all cached queries
|
||||
*/
|
||||
private invalidateCache(): void {
|
||||
this.queryCache.clear()
|
||||
this.cacheSize = 0
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Spatial bounds calculations for node layouts
|
||||
*/
|
||||
|
||||
export interface SpatialBounds {
|
||||
interface SpatialBounds {
|
||||
minX: number
|
||||
minY: number
|
||||
maxX: number
|
||||
@@ -11,7 +11,7 @@ export interface SpatialBounds {
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface PositionedNode {
|
||||
interface PositionedNode {
|
||||
pos: ArrayLike<number>
|
||||
size: ArrayLike<number>
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import {
|
||||
calculateMinimapScale,
|
||||
calculateNodeBounds
|
||||
} from '@/renderer/core/spatial/boundsCalculator'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
import { renderMinimapToCanvas } from '../extensions/minimap/minimapCanvasRenderer'
|
||||
import { renderMinimapToCanvas } from '../../extensions/minimap/minimapCanvasRenderer'
|
||||
|
||||
/**
|
||||
* Create a thumbnail of the current canvas's active graph.
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { createGraphThumbnail } from '@/renderer/thumbnail/graphThumbnailRenderer'
|
||||
import { ComfyWorkflow } from '@/stores/workflowStore'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
import { createGraphThumbnail } from './graphThumbnailRenderer'
|
||||
|
||||
// Store thumbnails for each workflow
|
||||
const workflowThumbnails = ref<Map<string, string>>(new Map())
|
||||
147
src/renderer/extensions/minimap/MiniMap.vue
Normal file
147
src/renderer/extensions/minimap/MiniMap.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible && initialized"
|
||||
ref="minimapRef"
|
||||
class="minimap-main-container flex absolute bottom-[66px] right-2 md:right-11 z-1000"
|
||||
>
|
||||
<MiniMapPanel
|
||||
v-if="showOptionsPanel"
|
||||
:panel-styles="panelStyles"
|
||||
:node-colors="nodeColors"
|
||||
:show-links="showLinks"
|
||||
:show-groups="showGroups"
|
||||
:render-bypass="renderBypass"
|
||||
:render-error="renderError"
|
||||
@update-option="updateOption"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="litegraph-minimap relative"
|
||||
:style="containerStyles"
|
||||
>
|
||||
<Button
|
||||
class="absolute z-10"
|
||||
size="small"
|
||||
text
|
||||
severity="secondary"
|
||||
@click.stop="toggleOptionsPanel"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:settings-2 />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
class="absolute z-10 right-0"
|
||||
size="small"
|
||||
text
|
||||
severity="secondary"
|
||||
data-testid="close-minmap-button"
|
||||
@click.stop="() => commandStore.execute('Comfy.Canvas.ToggleMinimap')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:x />
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<hr
|
||||
class="absolute top-5 bg-[#E1DED5] dark-theme:bg-[#262729] h-[1px] border-0"
|
||||
:style="{
|
||||
width: containerStyles.width
|
||||
}"
|
||||
/>
|
||||
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
:width="width"
|
||||
:height="height"
|
||||
class="minimap-canvas"
|
||||
/>
|
||||
|
||||
<div class="minimap-viewport" :style="viewportStyles" />
|
||||
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointerleave="handlePointerUp"
|
||||
@wheel="handleWheel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import MiniMapPanel from './MiniMapPanel.vue'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
const minimapRef = ref<HTMLDivElement>()
|
||||
|
||||
const {
|
||||
initialized,
|
||||
visible,
|
||||
containerRef,
|
||||
canvasRef,
|
||||
containerStyles,
|
||||
viewportStyles,
|
||||
width,
|
||||
height,
|
||||
panelStyles,
|
||||
nodeColors,
|
||||
showLinks,
|
||||
showGroups,
|
||||
renderBypass,
|
||||
renderError,
|
||||
updateOption,
|
||||
destroy,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handleWheel,
|
||||
setMinimapRef
|
||||
} = useMinimap()
|
||||
|
||||
const showOptionsPanel = ref(false)
|
||||
|
||||
const toggleOptionsPanel = () => {
|
||||
showOptionsPanel.value = !showOptionsPanel.value
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (minimapRef.value) {
|
||||
setMinimapRef(minimapRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.litegraph-minimap {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.minimap-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.minimap-viewport {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
97
src/renderer/extensions/minimap/MiniMapPanel.vue
Normal file
97
src/renderer/extensions/minimap/MiniMapPanel.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div
|
||||
class="minimap-panel p-3 mr-2 flex flex-col gap-3 text-sm"
|
||||
:style="panelStyles"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
input-id="node-colors"
|
||||
name="node-colors"
|
||||
:model-value="nodeColors"
|
||||
binary
|
||||
@update:model-value="
|
||||
(value) => $emit('updateOption', 'Comfy.Minimap.NodeColors', value)
|
||||
"
|
||||
/>
|
||||
<i-lucide:palette />
|
||||
<label for="node-colors">{{ $t('minimap.nodeColors') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
input-id="show-links"
|
||||
name="show-links"
|
||||
:model-value="showLinks"
|
||||
binary
|
||||
@update:model-value="
|
||||
(value) => $emit('updateOption', 'Comfy.Minimap.ShowLinks', value)
|
||||
"
|
||||
/>
|
||||
<i-lucide:route />
|
||||
<label for="show-links">{{ $t('minimap.showLinks') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
input-id="show-groups"
|
||||
name="show-groups"
|
||||
:model-value="showGroups"
|
||||
binary
|
||||
@update:model-value="
|
||||
(value) => $emit('updateOption', 'Comfy.Minimap.ShowGroups', value)
|
||||
"
|
||||
/>
|
||||
<i-lucide:frame />
|
||||
<label for="show-groups">{{ $t('minimap.showGroups') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
input-id="render-bypass"
|
||||
name="render-bypass"
|
||||
:model-value="renderBypass"
|
||||
binary
|
||||
@update:model-value="
|
||||
(value) =>
|
||||
$emit('updateOption', 'Comfy.Minimap.RenderBypassState', value)
|
||||
"
|
||||
/>
|
||||
<i-lucide:circle-slash-2 />
|
||||
<label for="render-bypass">{{ $t('minimap.renderBypassState') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
input-id="render-error"
|
||||
name="render-error"
|
||||
:model-value="renderError"
|
||||
binary
|
||||
@update:model-value="
|
||||
(value) =>
|
||||
$emit('updateOption', 'Comfy.Minimap.RenderErrorState', value)
|
||||
"
|
||||
/>
|
||||
<i-lucide:message-circle-warning />
|
||||
<label for="render-error">{{ $t('minimap.renderErrorState') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
|
||||
import type { MinimapSettingsKey } from '@/renderer/extensions/minimap/types'
|
||||
|
||||
defineProps<{
|
||||
panelStyles: any
|
||||
nodeColors: boolean
|
||||
showLinks: boolean
|
||||
showGroups: boolean
|
||||
renderBypass: boolean
|
||||
renderError: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
updateOption: [key: MinimapSettingsKey, value: boolean]
|
||||
}>()
|
||||
</script>
|
||||
@@ -2,9 +2,9 @@ import { useRafFn } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import type { MinimapCanvas, MinimapSettingsKey } from '../types'
|
||||
import { useMinimapGraph } from './useMinimapGraph'
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import { MinimapDataSourceFactory } from '../data/MinimapDataSourceFactory'
|
||||
import type { UpdateFlags } from '../types'
|
||||
|
||||
interface GraphCallbacks {
|
||||
@@ -28,6 +30,9 @@ export function useMinimapGraph(
|
||||
viewport: false
|
||||
})
|
||||
|
||||
// Track LayoutStore version for change detection
|
||||
const layoutStoreVersion = layoutStore.getVersion()
|
||||
|
||||
// Map to store original callbacks per graph ID
|
||||
const originalCallbacksMap = new Map<string, GraphCallbacks>()
|
||||
|
||||
@@ -96,28 +101,30 @@ export function useMinimapGraph(
|
||||
let positionChanged = false
|
||||
let connectionChanged = false
|
||||
|
||||
if (g._nodes.length !== lastNodeCount.value) {
|
||||
// Use unified data source for change detection
|
||||
const dataSource = MinimapDataSourceFactory.create(g)
|
||||
|
||||
// Check for node count changes
|
||||
const currentNodeCount = dataSource.getNodeCount()
|
||||
if (currentNodeCount !== lastNodeCount.value) {
|
||||
structureChanged = true
|
||||
lastNodeCount.value = g._nodes.length
|
||||
lastNodeCount.value = currentNodeCount
|
||||
}
|
||||
|
||||
for (const node of g._nodes) {
|
||||
const key = node.id
|
||||
const currentState = `${node.pos[0]},${node.pos[1]},${node.size[0]},${node.size[1]}`
|
||||
// Check for node position/size changes
|
||||
const nodes = dataSource.getNodes()
|
||||
for (const node of nodes) {
|
||||
const nodeId = node.id
|
||||
const currentState = `${node.x},${node.y},${node.width},${node.height}`
|
||||
|
||||
if (nodeStatesCache.get(key) !== currentState) {
|
||||
if (nodeStatesCache.get(nodeId) !== currentState) {
|
||||
positionChanged = true
|
||||
nodeStatesCache.set(key, currentState)
|
||||
nodeStatesCache.set(nodeId, currentState)
|
||||
}
|
||||
}
|
||||
|
||||
const currentLinks = JSON.stringify(g.links || {})
|
||||
if (currentLinks !== linksCache.value) {
|
||||
connectionChanged = true
|
||||
linksCache.value = currentLinks
|
||||
}
|
||||
|
||||
const currentNodeIds = new Set(g._nodes.map((n: LGraphNode) => n.id))
|
||||
// Clean up removed nodes from cache
|
||||
const currentNodeIds = new Set(nodes.map((n) => n.id))
|
||||
for (const [nodeId] of nodeStatesCache) {
|
||||
if (!currentNodeIds.has(nodeId)) {
|
||||
nodeStatesCache.delete(nodeId)
|
||||
@@ -125,6 +132,13 @@ export function useMinimapGraph(
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: update when Layoutstore tracks links
|
||||
const currentLinks = JSON.stringify(g.links || {})
|
||||
if (currentLinks !== linksCache.value) {
|
||||
connectionChanged = true
|
||||
linksCache.value = currentLinks
|
||||
}
|
||||
|
||||
if (structureChanged || positionChanged) {
|
||||
updateFlags.value.bounds = true
|
||||
updateFlags.value.nodes = true
|
||||
@@ -140,6 +154,10 @@ export function useMinimapGraph(
|
||||
const init = () => {
|
||||
setupEventListeners()
|
||||
api.addEventListener('graphChanged', handleGraphChangedThrottled)
|
||||
|
||||
watch(layoutStoreVersion, () => {
|
||||
void handleGraphChangedThrottled()
|
||||
})
|
||||
}
|
||||
|
||||
const destroy = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
calculateMinimapScale,
|
||||
calculateNodeBounds,
|
||||
enforceMinimumBounds
|
||||
} from '@/renderer/core/spatial/boundsCalculator'
|
||||
import { MinimapDataSourceFactory } from '@/renderer/extensions/minimap/data/MinimapDataSourceFactory'
|
||||
|
||||
import type { MinimapBounds, MinimapCanvas, ViewportTransform } from '../types'
|
||||
|
||||
@@ -53,17 +53,15 @@ export function useMinimapViewport(
|
||||
}
|
||||
|
||||
const calculateGraphBounds = (): MinimapBounds => {
|
||||
const g = graph.value
|
||||
if (!g || !g._nodes || g._nodes.length === 0) {
|
||||
// Use unified data source
|
||||
const dataSource = MinimapDataSourceFactory.create(graph.value)
|
||||
|
||||
if (!dataSource.hasData()) {
|
||||
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
|
||||
}
|
||||
|
||||
const bounds = calculateNodeBounds(g._nodes)
|
||||
if (!bounds) {
|
||||
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
|
||||
}
|
||||
|
||||
return enforceMinimumBounds(bounds)
|
||||
const sourceBounds = dataSource.getBounds()
|
||||
return enforceMinimumBounds(sourceBounds)
|
||||
}
|
||||
|
||||
const calculateScale = () => {
|
||||
@@ -126,9 +124,8 @@ export function useMinimapViewport(
|
||||
|
||||
c.setDirty(true, true)
|
||||
}
|
||||
|
||||
const { startSync: startViewportSync, stopSync: stopViewportSync } =
|
||||
useCanvasTransformSync(updateViewport, { autoStart: false })
|
||||
const { resume: startViewportSync, pause: stopViewportSync } =
|
||||
useRafFn(updateViewport)
|
||||
|
||||
return {
|
||||
bounds: computed(() => bounds.value),
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { calculateNodeBounds } from '@/renderer/core/spatial/boundsCalculator'
|
||||
|
||||
import type {
|
||||
IMinimapDataSource,
|
||||
MinimapBounds,
|
||||
MinimapGroupData,
|
||||
MinimapLinkData,
|
||||
MinimapNodeData
|
||||
} from '../types'
|
||||
|
||||
/**
|
||||
* Abstract base class for minimap data sources
|
||||
* Provides common functionality and shared implementation
|
||||
*/
|
||||
export abstract class AbstractMinimapDataSource implements IMinimapDataSource {
|
||||
constructor(protected graph: LGraph | null) {}
|
||||
|
||||
// Abstract methods that must be implemented by subclasses
|
||||
abstract getNodes(): MinimapNodeData[]
|
||||
abstract getNodeCount(): number
|
||||
abstract hasData(): boolean
|
||||
|
||||
// Shared implementation using calculateNodeBounds
|
||||
getBounds(): MinimapBounds {
|
||||
const nodes = this.getNodes()
|
||||
if (nodes.length === 0) {
|
||||
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
|
||||
}
|
||||
|
||||
// Convert MinimapNodeData to the format expected by calculateNodeBounds
|
||||
const compatibleNodes = nodes.map((node) => ({
|
||||
pos: [node.x, node.y],
|
||||
size: [node.width, node.height]
|
||||
}))
|
||||
|
||||
const bounds = calculateNodeBounds(compatibleNodes)
|
||||
if (!bounds) {
|
||||
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
|
||||
}
|
||||
|
||||
return bounds
|
||||
}
|
||||
|
||||
// Shared implementation for groups
|
||||
getGroups(): MinimapGroupData[] {
|
||||
if (!this.graph?._groups) return []
|
||||
return this.graph._groups.map((group) => ({
|
||||
x: group.pos[0],
|
||||
y: group.pos[1],
|
||||
width: group.size[0],
|
||||
height: group.size[1],
|
||||
color: group.color
|
||||
}))
|
||||
}
|
||||
|
||||
// TODO: update when Layoutstore supports links
|
||||
getLinks(): MinimapLinkData[] {
|
||||
if (!this.graph) return []
|
||||
return this.extractLinksFromGraph(this.graph)
|
||||
}
|
||||
|
||||
protected extractLinksFromGraph(graph: LGraph): MinimapLinkData[] {
|
||||
const links: MinimapLinkData[] = []
|
||||
const nodeMap = new Map(this.getNodes().map((n) => [n.id, n]))
|
||||
|
||||
for (const node of graph._nodes) {
|
||||
if (!node.outputs) continue
|
||||
|
||||
const sourceNodeData = nodeMap.get(String(node.id))
|
||||
if (!sourceNodeData) continue
|
||||
|
||||
for (const output of node.outputs) {
|
||||
if (!output.links) continue
|
||||
|
||||
for (const linkId of output.links) {
|
||||
const link = graph.links[linkId]
|
||||
if (!link) continue
|
||||
|
||||
const targetNodeData = nodeMap.get(String(link.target_id))
|
||||
if (!targetNodeData) continue
|
||||
|
||||
links.push({
|
||||
sourceNode: sourceNodeData,
|
||||
targetNode: targetNodeData,
|
||||
sourceSlot: link.origin_slot,
|
||||
targetSlot: link.target_slot
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
import type { MinimapNodeData } from '../types'
|
||||
import { AbstractMinimapDataSource } from './AbstractMinimapDataSource'
|
||||
|
||||
/**
|
||||
* Layout Store data source implementation
|
||||
*/
|
||||
export class LayoutStoreDataSource extends AbstractMinimapDataSource {
|
||||
getNodes(): MinimapNodeData[] {
|
||||
const allNodes = layoutStore.getAllNodes().value
|
||||
if (allNodes.size === 0) return []
|
||||
|
||||
const nodes: MinimapNodeData[] = []
|
||||
|
||||
for (const [nodeId, layout] of allNodes) {
|
||||
// Find corresponding LiteGraph node for additional properties
|
||||
const graphNode = this.graph?._nodes?.find((n) => String(n.id) === nodeId)
|
||||
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
x: layout.position.x,
|
||||
y: layout.position.y,
|
||||
width: layout.size.width,
|
||||
height: layout.size.height,
|
||||
bgcolor: graphNode?.bgcolor,
|
||||
mode: graphNode?.mode,
|
||||
hasErrors: graphNode?.has_errors
|
||||
})
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
getNodeCount(): number {
|
||||
return layoutStore.getAllNodes().value.size
|
||||
}
|
||||
|
||||
hasData(): boolean {
|
||||
return this.getNodeCount() > 0
|
||||
}
|
||||
}
|
||||
30
src/renderer/extensions/minimap/data/LiteGraphDataSource.ts
Normal file
30
src/renderer/extensions/minimap/data/LiteGraphDataSource.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { MinimapNodeData } from '../types'
|
||||
import { AbstractMinimapDataSource } from './AbstractMinimapDataSource'
|
||||
|
||||
/**
|
||||
* LiteGraph data source implementation
|
||||
*/
|
||||
export class LiteGraphDataSource extends AbstractMinimapDataSource {
|
||||
getNodes(): MinimapNodeData[] {
|
||||
if (!this.graph?._nodes) return []
|
||||
|
||||
return this.graph._nodes.map((node) => ({
|
||||
id: String(node.id),
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1],
|
||||
bgcolor: node.bgcolor,
|
||||
mode: node.mode,
|
||||
hasErrors: node.has_errors
|
||||
}))
|
||||
}
|
||||
|
||||
getNodeCount(): number {
|
||||
return this.graph?._nodes?.length ?? 0
|
||||
}
|
||||
|
||||
hasData(): boolean {
|
||||
return this.getNodeCount() > 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
import type { IMinimapDataSource } from '../types'
|
||||
import { LayoutStoreDataSource } from './LayoutStoreDataSource'
|
||||
import { LiteGraphDataSource } from './LiteGraphDataSource'
|
||||
|
||||
/**
|
||||
* Factory for creating the appropriate data source
|
||||
*/
|
||||
export class MinimapDataSourceFactory {
|
||||
static create(graph: LGraph | null): IMinimapDataSource {
|
||||
// Check if LayoutStore has data
|
||||
const layoutStoreHasData = layoutStore.getAllNodes().value.size > 0
|
||||
|
||||
if (layoutStoreHasData) {
|
||||
return new LayoutStoreDataSource(graph)
|
||||
}
|
||||
|
||||
return new LiteGraphDataSource(graph)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { LGraph, LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
|
||||
import type { MinimapRenderContext } from './types'
|
||||
import { MinimapDataSourceFactory } from './data/MinimapDataSourceFactory'
|
||||
import type {
|
||||
IMinimapDataSource,
|
||||
MinimapNodeData,
|
||||
MinimapRenderContext
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Get theme-aware colors for the minimap
|
||||
@@ -24,24 +30,49 @@ function getMinimapColors() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get node color based on settings and node properties (Single Responsibility)
|
||||
*/
|
||||
function getNodeColor(
|
||||
node: MinimapNodeData,
|
||||
settings: MinimapRenderContext['settings'],
|
||||
colors: ReturnType<typeof getMinimapColors>
|
||||
): string {
|
||||
if (settings.renderBypass && node.mode === LGraphEventMode.BYPASS) {
|
||||
return colors.bypassColor
|
||||
}
|
||||
|
||||
if (settings.nodeColors) {
|
||||
if (node.bgcolor) {
|
||||
return colors.isLightTheme
|
||||
? adjustColor(node.bgcolor, { lightness: 0.5 })
|
||||
: node.bgcolor
|
||||
}
|
||||
return colors.nodeColorDefault
|
||||
}
|
||||
|
||||
return colors.nodeColor
|
||||
}
|
||||
|
||||
/**
|
||||
* Render groups on the minimap
|
||||
*/
|
||||
function renderGroups(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
graph: LGraph,
|
||||
dataSource: IMinimapDataSource,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
context: MinimapRenderContext,
|
||||
colors: ReturnType<typeof getMinimapColors>
|
||||
) {
|
||||
if (!graph._groups || graph._groups.length === 0) return
|
||||
const groups = dataSource.getGroups()
|
||||
if (groups.length === 0) return
|
||||
|
||||
for (const group of graph._groups) {
|
||||
const x = (group.pos[0] - context.bounds.minX) * context.scale + offsetX
|
||||
const y = (group.pos[1] - context.bounds.minY) * context.scale + offsetY
|
||||
const w = group.size[0] * context.scale
|
||||
const h = group.size[1] * context.scale
|
||||
for (const group of groups) {
|
||||
const x = (group.x - context.bounds.minX) * context.scale + offsetX
|
||||
const y = (group.y - context.bounds.minY) * context.scale + offsetY
|
||||
const w = group.width * context.scale
|
||||
const h = group.height * context.scale
|
||||
|
||||
let color = colors.groupColor
|
||||
|
||||
@@ -63,45 +94,34 @@ function renderGroups(
|
||||
*/
|
||||
function renderNodes(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
graph: LGraph,
|
||||
dataSource: IMinimapDataSource,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
context: MinimapRenderContext,
|
||||
colors: ReturnType<typeof getMinimapColors>
|
||||
) {
|
||||
if (!graph._nodes || graph._nodes.length === 0) return
|
||||
const nodes = dataSource.getNodes()
|
||||
if (nodes.length === 0) return
|
||||
|
||||
// Group nodes by color for batch rendering
|
||||
// Group nodes by color for batch rendering (performance optimization)
|
||||
const nodesByColor = new Map<
|
||||
string,
|
||||
Array<{ x: number; y: number; w: number; h: number; hasErrors?: boolean }>
|
||||
>()
|
||||
|
||||
for (const node of graph._nodes) {
|
||||
const x = (node.pos[0] - context.bounds.minX) * context.scale + offsetX
|
||||
const y = (node.pos[1] - context.bounds.minY) * context.scale + offsetY
|
||||
const w = node.size[0] * context.scale
|
||||
const h = node.size[1] * context.scale
|
||||
for (const node of nodes) {
|
||||
const x = (node.x - context.bounds.minX) * context.scale + offsetX
|
||||
const y = (node.y - context.bounds.minY) * context.scale + offsetY
|
||||
const w = node.width * context.scale
|
||||
const h = node.height * context.scale
|
||||
|
||||
let color = colors.nodeColor
|
||||
|
||||
if (context.settings.renderBypass && node.mode === LGraphEventMode.BYPASS) {
|
||||
color = colors.bypassColor
|
||||
} else if (context.settings.nodeColors) {
|
||||
color = colors.nodeColorDefault
|
||||
|
||||
if (node.bgcolor) {
|
||||
color = colors.isLightTheme
|
||||
? adjustColor(node.bgcolor, { lightness: 0.5 })
|
||||
: node.bgcolor
|
||||
}
|
||||
}
|
||||
const color = getNodeColor(node, context.settings, colors)
|
||||
|
||||
if (!nodesByColor.has(color)) {
|
||||
nodesByColor.set(color, [])
|
||||
}
|
||||
|
||||
nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.has_errors })
|
||||
nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.hasErrors })
|
||||
}
|
||||
|
||||
// Batch render nodes by color
|
||||
@@ -131,13 +151,14 @@ function renderNodes(
|
||||
*/
|
||||
function renderConnections(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
graph: LGraph,
|
||||
dataSource: IMinimapDataSource,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
context: MinimapRenderContext,
|
||||
colors: ReturnType<typeof getMinimapColors>
|
||||
) {
|
||||
if (!graph || !graph._nodes) return
|
||||
const links = dataSource.getLinks()
|
||||
if (links.length === 0) return
|
||||
|
||||
ctx.strokeStyle = colors.linkColor
|
||||
ctx.lineWidth = 0.3
|
||||
@@ -150,41 +171,28 @@ function renderConnections(
|
||||
y2: number
|
||||
}> = []
|
||||
|
||||
for (const node of graph._nodes) {
|
||||
if (!node.outputs) continue
|
||||
for (const link of links) {
|
||||
const x1 =
|
||||
(link.sourceNode.x - context.bounds.minX) * context.scale + offsetX
|
||||
const y1 =
|
||||
(link.sourceNode.y - context.bounds.minY) * context.scale + offsetY
|
||||
const x2 =
|
||||
(link.targetNode.x - context.bounds.minX) * context.scale + offsetX
|
||||
const y2 =
|
||||
(link.targetNode.y - context.bounds.minY) * context.scale + offsetY
|
||||
|
||||
const x1 = (node.pos[0] - context.bounds.minX) * context.scale + offsetX
|
||||
const y1 = (node.pos[1] - context.bounds.minY) * context.scale + offsetY
|
||||
const outputX = x1 + link.sourceNode.width * context.scale
|
||||
const outputY = y1 + link.sourceNode.height * context.scale * 0.2
|
||||
const inputX = x2
|
||||
const inputY = y2 + link.targetNode.height * context.scale * 0.2
|
||||
|
||||
for (const output of node.outputs) {
|
||||
if (!output.links) continue
|
||||
// Draw connection line
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(outputX, outputY)
|
||||
ctx.lineTo(inputX, inputY)
|
||||
ctx.stroke()
|
||||
|
||||
for (const linkId of output.links) {
|
||||
const link = graph.links[linkId]
|
||||
if (!link) continue
|
||||
|
||||
const targetNode = graph.getNodeById(link.target_id)
|
||||
if (!targetNode) continue
|
||||
|
||||
const x2 =
|
||||
(targetNode.pos[0] - context.bounds.minX) * context.scale + offsetX
|
||||
const y2 =
|
||||
(targetNode.pos[1] - context.bounds.minY) * context.scale + offsetY
|
||||
|
||||
const outputX = x1 + node.size[0] * context.scale
|
||||
const outputY = y1 + node.size[1] * context.scale * 0.2
|
||||
const inputX = x2
|
||||
const inputY = y2 + targetNode.size[1] * context.scale * 0.2
|
||||
|
||||
// Draw connection line
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(outputX, outputY)
|
||||
ctx.lineTo(inputX, inputY)
|
||||
ctx.stroke()
|
||||
|
||||
connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY })
|
||||
}
|
||||
}
|
||||
connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY })
|
||||
}
|
||||
|
||||
// Render connection slots on top
|
||||
@@ -216,8 +224,11 @@ export function renderMinimapToCanvas(
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, context.width, context.height)
|
||||
|
||||
// Create unified data source (Dependency Inversion)
|
||||
const dataSource = MinimapDataSourceFactory.create(graph)
|
||||
|
||||
// Fast path for empty graph
|
||||
if (!graph || !graph._nodes || graph._nodes.length === 0) {
|
||||
if (!dataSource.hasData()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -227,12 +238,12 @@ export function renderMinimapToCanvas(
|
||||
|
||||
// Render in correct order: groups -> links -> nodes
|
||||
if (context.settings.showGroups) {
|
||||
renderGroups(ctx, graph, offsetX, offsetY, context, colors)
|
||||
renderGroups(ctx, dataSource, offsetX, offsetY, context, colors)
|
||||
}
|
||||
|
||||
if (context.settings.showLinks) {
|
||||
renderConnections(ctx, graph, offsetX, offsetY, context, colors)
|
||||
renderConnections(ctx, dataSource, offsetX, offsetY, context, colors)
|
||||
}
|
||||
|
||||
renderNodes(ctx, graph, offsetX, offsetY, context, colors)
|
||||
renderNodes(ctx, dataSource, offsetX, offsetY, context, colors)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Minimap-specific type definitions
|
||||
*/
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
/**
|
||||
* Minimal interface for what the minimap needs from the canvas
|
||||
@@ -29,7 +30,7 @@ export interface MinimapRenderContext {
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface MinimapRenderSettings {
|
||||
interface MinimapRenderSettings {
|
||||
nodeColors: boolean
|
||||
showLinks: boolean
|
||||
showGroups: boolean
|
||||
@@ -66,3 +67,50 @@ export type MinimapSettingsKey =
|
||||
| 'Comfy.Minimap.ShowGroups'
|
||||
| 'Comfy.Minimap.RenderBypassState'
|
||||
| 'Comfy.Minimap.RenderErrorState'
|
||||
|
||||
/**
|
||||
* Node data required for minimap rendering
|
||||
*/
|
||||
export interface MinimapNodeData {
|
||||
id: NodeId
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
bgcolor?: string
|
||||
mode?: number
|
||||
hasErrors?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Link data required for minimap rendering
|
||||
*/
|
||||
export interface MinimapLinkData {
|
||||
sourceNode: MinimapNodeData
|
||||
targetNode: MinimapNodeData
|
||||
sourceSlot: number
|
||||
targetSlot: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Group data required for minimap rendering
|
||||
*/
|
||||
export interface MinimapGroupData {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
color?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for minimap data sources (Dependency Inversion Principle)
|
||||
*/
|
||||
export interface IMinimapDataSource {
|
||||
getNodes(): MinimapNodeData[]
|
||||
getLinks(): MinimapLinkData[]
|
||||
getGroups(): MinimapGroupData[]
|
||||
getBounds(): MinimapBounds
|
||||
getNodeCount(): number
|
||||
hasData(): boolean
|
||||
}
|
||||
|
||||
263
src/renderer/extensions/vueNodes/components/ImagePreview.vue
Normal file
263
src/renderer/extensions/vueNodes/components/ImagePreview.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="imageUrls.length > 0"
|
||||
class="image-preview relative group flex flex-col items-center"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
:aria-label="$t('g.imagePreview')"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<!-- Image Wrapper -->
|
||||
<div
|
||||
class="relative rounded-[5px] overflow-hidden w-full max-w-[352px] bg-[#262729]"
|
||||
>
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-if="imageError"
|
||||
class="w-full h-[352px] flex flex-col items-center justify-center text-white text-center bg-gray-800/50"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<Skeleton
|
||||
v-else-if="isLoading"
|
||||
class="w-full h-[352px]"
|
||||
border-radius="5px"
|
||||
/>
|
||||
|
||||
<!-- Main Image -->
|
||||
<img
|
||||
v-else
|
||||
:src="currentImageUrl"
|
||||
:alt="imageAltText"
|
||||
class="w-full h-[352px] object-contain block"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- Floating Action Buttons (appear on hover) -->
|
||||
<div v-if="isHovered" class="actions absolute top-2 right-2 flex gap-1">
|
||||
<!-- Mask/Edit Button -->
|
||||
<button
|
||||
v-if="!hasMultipleImages"
|
||||
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.editOrMaskImage')"
|
||||
:aria-label="$t('g.editOrMaskImage')"
|
||||
@click="handleEditMask"
|
||||
>
|
||||
<i-lucide:venetian-mask class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<!-- 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.downloadImage')"
|
||||
:aria-label="$t('g.downloadImage')"
|
||||
@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.removeImage')"
|
||||
:aria-label="$t('g.removeImage')"
|
||||
@click="handleRemove"
|
||||
>
|
||||
<i-lucide:x class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Multiple Images Navigation -->
|
||||
<div
|
||||
v-if="hasMultipleImages"
|
||||
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.viewImageOfTotal', {
|
||||
index: index + 1,
|
||||
total: imageUrls.length
|
||||
})
|
||||
"
|
||||
@click="setCurrentIndex(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Image Dimensions -->
|
||||
<div class="text-white text-xs text-center mt-2">
|
||||
<span v-if="imageError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingImage') }}
|
||||
</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 { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
|
||||
interface ImagePreviewProps {
|
||||
/** Array of image URLs to display */
|
||||
readonly imageUrls: readonly string[]
|
||||
/** Optional node ID for context-aware actions */
|
||||
readonly nodeId?: string
|
||||
}
|
||||
|
||||
const props = defineProps<ImagePreviewProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
// Component state
|
||||
const currentIndex = ref(0)
|
||||
const isHovered = ref(false)
|
||||
const actualDimensions = ref<string | null>(null)
|
||||
const imageError = ref(false)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Computed values
|
||||
const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
|
||||
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
|
||||
const imageAltText = computed(() => `Node output ${currentIndex.value + 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
|
||||
imageError.value = false
|
||||
isLoading.value = false
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Event handlers
|
||||
const handleImageLoad = (event: Event) => {
|
||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
||||
const img = event.target
|
||||
isLoading.value = false
|
||||
imageError.value = false
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
isLoading.value = false
|
||||
imageError.value = true
|
||||
actualDimensions.value = null
|
||||
}
|
||||
|
||||
const handleEditMask = () => {
|
||||
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
try {
|
||||
downloadFile(currentImageUrl.value)
|
||||
} catch (error) {
|
||||
useToast().add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: t('g.failedToDownloadImage'),
|
||||
life: 3000,
|
||||
group: 'image-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
|
||||
imageError.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isHovered.value = true
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHovered.value = false
|
||||
}
|
||||
|
||||
const getNavigationDotClass = (index: number) => {
|
||||
return [
|
||||
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer',
|
||||
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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
154
src/renderer/extensions/vueNodes/components/InputSlot.vue
Normal file
154
src/renderer/extensions/vueNodes/components/InputSlot.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs">⚠️</div>
|
||||
<div v-else v-tooltip.left="tooltipConfig" :class="slotWrapperClass">
|
||||
<!-- Connection Dot -->
|
||||
<SlotConnectionDot
|
||||
ref="connectionDotRef"
|
||||
:color="slotColor"
|
||||
:class="cn('-translate-x-1/2', errorClassesDot)"
|
||||
v-on="readonly ? {} : { pointerdown: onPointerDown }"
|
||||
/>
|
||||
|
||||
<!-- Slot Name -->
|
||||
<div class="relative">
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
:class="
|
||||
cn('whitespace-nowrap text-sm font-normal lod-toggle', labelClasses)
|
||||
"
|
||||
>
|
||||
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
|
||||
</span>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type ComponentPublicInstance,
|
||||
type Ref,
|
||||
computed,
|
||||
inject,
|
||||
onErrorCaptured,
|
||||
ref,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { getSlotColor } from '@/constants/slotColors'
|
||||
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
interface InputSlotProps {
|
||||
nodeType?: string
|
||||
nodeId?: string
|
||||
slotData: INodeSlot
|
||||
index: number
|
||||
connected?: boolean
|
||||
compatible?: boolean
|
||||
readonly?: boolean
|
||||
dotOnly?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<InputSlotProps>()
|
||||
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const hasSlotError = computed(() => {
|
||||
const nodeErrors = executionStore.lastNodeErrors?.[props.nodeId ?? '']
|
||||
if (!nodeErrors) return false
|
||||
|
||||
const slotName = props.slotData.name
|
||||
return nodeErrors.errors.some(
|
||||
(error) => error.extra_info?.input_name === slotName
|
||||
)
|
||||
})
|
||||
|
||||
const errorClassesDot = computed(() => {
|
||||
return hasSlotError.value
|
||||
? 'ring-2 ring-error dark-theme: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'
|
||||
)
|
||||
|
||||
const renderError = ref<string | null>(null)
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
const tooltipContainer =
|
||||
inject<Ref<HTMLElement | undefined>>('tooltipContainer')
|
||||
const { getInputSlotTooltip, createTooltipConfig } = useNodeTooltips(
|
||||
props.nodeType || '',
|
||||
tooltipContainer
|
||||
)
|
||||
|
||||
const tooltipConfig = computed(() => {
|
||||
const slotName = props.slotData.localized_name || props.slotData.name || ''
|
||||
const tooltipText = getInputSlotTooltip(slotName)
|
||||
const fallbackText = tooltipText || `Input: ${slotName}`
|
||||
return createTooltipConfig(fallbackText)
|
||||
})
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
toastErrorHandler(error)
|
||||
return false
|
||||
})
|
||||
|
||||
const slotColor = computed(() => {
|
||||
if (hasSlotError.value) {
|
||||
return 'var(--color-error)'
|
||||
}
|
||||
return getSlotColor(props.slotData.type)
|
||||
})
|
||||
|
||||
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',
|
||||
props.dotOnly
|
||||
? 'lg-slot--dot-only'
|
||||
: 'pr-6 hover:bg-black/5 hover:dark:bg-white/5',
|
||||
{
|
||||
'lg-slot--connected': props.connected,
|
||||
'lg-slot--compatible': props.compatible
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const connectionDotRef = ref<ComponentPublicInstance<{
|
||||
slotElRef: HTMLElement | undefined
|
||||
}> | null>(null)
|
||||
const slotElRef = ref<HTMLElement | null>(null)
|
||||
|
||||
watchEffect(() => {
|
||||
const el = connectionDotRef.value?.slotElRef
|
||||
slotElRef.value = el || null
|
||||
})
|
||||
|
||||
useSlotElementTracking({
|
||||
nodeId: props.nodeId ?? '',
|
||||
index: props.index,
|
||||
type: 'input',
|
||||
element: slotElRef
|
||||
})
|
||||
|
||||
const { onPointerDown } = useSlotLinkInteraction({
|
||||
nodeId: props.nodeId ?? '',
|
||||
index: props.index,
|
||||
type: 'input',
|
||||
readonly: props.readonly
|
||||
})
|
||||
</script>
|
||||
397
src/renderer/extensions/vueNodes/components/LGraphNode.vue
Normal file
397
src/renderer/extensions/vueNodes/components/LGraphNode.vue
Normal file
@@ -0,0 +1,397 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
{{ $t('Node Render Error') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
ref="nodeContainerRef"
|
||||
: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',
|
||||
// hover (only when node should handle events)
|
||||
shouldHandleNodePointerEvents &&
|
||||
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
|
||||
'outline-transparent -outline-offset-2 outline-2',
|
||||
borderClass,
|
||||
outlineClass,
|
||||
{
|
||||
'animate-pulse': executing,
|
||||
'opacity-50 before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
|
||||
bypassed,
|
||||
'opacity-50 before:rounded-2xl before:pointer-events-none before:absolute before:inset-0':
|
||||
muted,
|
||||
'will-change-transform': isDragging
|
||||
},
|
||||
|
||||
shouldHandleNodePointerEvents
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none'
|
||||
)
|
||||
"
|
||||
:style="[
|
||||
{
|
||||
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
|
||||
zIndex: zIndex,
|
||||
backgroundColor: nodeBodyBackgroundColor,
|
||||
opacity: nodeOpacity
|
||||
},
|
||||
dragStyle
|
||||
]"
|
||||
v-bind="pointerHandlers"
|
||||
@wheel="handleWheel"
|
||||
@contextmenu="handleContextMenu"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<template v-if="isCollapsed">
|
||||
<SlotConnectionDot multi class="absolute left-0 -translate-x-1/2" />
|
||||
<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"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCollapsed && executing && progress !== undefined"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-x-4 -bottom-[1px] translate-y-1/2 rounded-full',
|
||||
progressClasses
|
||||
)
|
||||
"
|
||||
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
|
||||
/>
|
||||
|
||||
<template v-if="!isCollapsed">
|
||||
<div class="mb-4 relative">
|
||||
<div :class="separatorClasses" />
|
||||
<!-- Progress bar for executing state -->
|
||||
<div
|
||||
v-if="executing && progress !== undefined"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-x-0 top-1/2 -translate-y-1/2',
|
||||
!!(progress < 1) && 'rounded-r-full',
|
||||
progressClasses
|
||||
)
|
||||
"
|
||||
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Node Body - rendered based on LOD level and collapsed state -->
|
||||
<div
|
||||
class="flex flex-col gap-4 pb-4"
|
||||
: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"
|
||||
/>
|
||||
|
||||
<!-- Widgets rendered at reduced+ detail -->
|
||||
<NodeWidgets
|
||||
v-if="nodeData.widgets?.length"
|
||||
v-memo="[nodeData.widgets?.length]"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
/>
|
||||
|
||||
<!-- Custom content at reduced+ detail -->
|
||||
<NodeContent
|
||||
v-if="hasCustomContent"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:image-urls="nodeImageUrls"
|
||||
/>
|
||||
<!-- Live preview image -->
|
||||
<div
|
||||
v-if="shouldShowPreviewImg"
|
||||
v-memo="[latestPreviewUrl]"
|
||||
class="px-4"
|
||||
>
|
||||
<img
|
||||
:src="latestPreviewUrl"
|
||||
alt="preview"
|
||||
class="w-full max-h-64 object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, inject, onErrorCaptured, onMounted, provide, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
|
||||
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
||||
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
|
||||
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
||||
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
|
||||
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import {
|
||||
getLocatorIdFromNodeData,
|
||||
getNodeByLocatorId
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import NodeContent from './NodeContent.vue'
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
import NodeSlots from './NodeSlots.vue'
|
||||
import NodeWidgets from './NodeWidgets.vue'
|
||||
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 {
|
||||
handleNodeCollapse,
|
||||
handleNodeTitleUpdate,
|
||||
handleNodeSelect,
|
||||
handleNodeRightClick
|
||||
} = useNodeEventHandlers()
|
||||
|
||||
useVueElementTracking(() => nodeData.id, 'node')
|
||||
|
||||
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||
|
||||
// Inject transform state for coordinate conversion
|
||||
const transformState = inject(TransformStateKey)
|
||||
|
||||
// Computed selection state - only this node re-evaluates when its selection changes
|
||||
const isSelected = computed(() => {
|
||||
return selectedNodeIds.value.has(nodeData.id)
|
||||
})
|
||||
|
||||
// Use execution state composable
|
||||
const { executing, progress } = useNodeExecutionState(() => nodeData.id)
|
||||
|
||||
// Direct access to execution store for error state
|
||||
const executionStore = useExecutionStore()
|
||||
const hasExecutionError = computed(
|
||||
() => executionStore.lastExecutionErrorNodeId === nodeData.id
|
||||
)
|
||||
|
||||
const hasAnyError = computed((): boolean => {
|
||||
return !!(
|
||||
hasExecutionError.value ||
|
||||
nodeData.hasErrors ||
|
||||
error ||
|
||||
(executionStore.lastNodeErrors?.[nodeData.id]?.errors.length ?? 0) > 0
|
||||
)
|
||||
})
|
||||
|
||||
const bypassed = computed((): boolean => nodeData.mode === 4)
|
||||
const muted = computed((): boolean => nodeData.mode === 2) // NEVER mode
|
||||
|
||||
const nodeBodyBackgroundColor = computed(() => {
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
|
||||
if (!nodeData.bgcolor) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return applyLightThemeColor(
|
||||
nodeData.bgcolor,
|
||||
Boolean(colorPaletteStore.completedActivePalette.light_theme)
|
||||
)
|
||||
})
|
||||
|
||||
const nodeOpacity = computed(
|
||||
() => useSettingStore().get('Comfy.Node.Opacity') ?? 1
|
||||
)
|
||||
|
||||
// Use canvas interactions for proper wheel event handling and pointer event capture control
|
||||
const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
toastErrorHandler(error)
|
||||
return false // Prevent error propagation
|
||||
})
|
||||
|
||||
// Use layout system for node position and dragging
|
||||
const { position, size, zIndex, resize } = useNodeLayout(() => nodeData.id)
|
||||
const { pointerHandlers, isDragging, dragStyle } = useNodePointerInteractions(
|
||||
() => nodeData,
|
||||
handleNodeSelect
|
||||
)
|
||||
|
||||
// Handle right-click context menu
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
// First handle the standard right-click behavior (selection)
|
||||
handleNodeRightClick(event as PointerEvent, nodeData)
|
||||
|
||||
// Show the node options menu at the cursor position
|
||||
const targetElement = event.currentTarget as HTMLElement
|
||||
if (targetElement) {
|
||||
toggleNodeOptions(event, targetElement, false)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (size.value && transformState?.camera) {
|
||||
const scale = transformState.camera.z
|
||||
const screenSize = {
|
||||
width: size.value.width * scale,
|
||||
height: size.value.height * scale
|
||||
}
|
||||
resize(screenSize)
|
||||
}
|
||||
})
|
||||
|
||||
// Track collapsed state
|
||||
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
|
||||
|
||||
// Check if node has custom content (like image outputs)
|
||||
const hasCustomContent = computed(() => {
|
||||
// Show custom content if node has image outputs
|
||||
return nodeImageUrls.value.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 progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
|
||||
|
||||
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
|
||||
() => nodeData.id,
|
||||
{
|
||||
isCollapsed
|
||||
}
|
||||
)
|
||||
|
||||
const borderClass = computed(() => {
|
||||
return (
|
||||
(hasAnyError.value && 'border-error dark-theme:border-error') ||
|
||||
(executing.value && 'border-blue-500')
|
||||
)
|
||||
})
|
||||
|
||||
const outlineClass = computed(() => {
|
||||
return (
|
||||
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')
|
||||
)
|
||||
})
|
||||
|
||||
// Event handlers
|
||||
const handleCollapse = () => {
|
||||
handleNodeCollapse(nodeData.id, !isCollapsed.value)
|
||||
}
|
||||
|
||||
const handleHeaderTitleUpdate = (newTitle: string) => {
|
||||
handleNodeTitleUpdate(nodeData.id, newTitle)
|
||||
}
|
||||
|
||||
const handleEnterSubgraph = () => {
|
||||
const graph = app.graph?.rootGraph || app.graph
|
||||
if (!graph) {
|
||||
console.warn('LGraphNode: No graph available for subgraph navigation')
|
||||
return
|
||||
}
|
||||
|
||||
const locatorId = getLocatorIdFromNodeData(nodeData)
|
||||
|
||||
const litegraphNode = getNodeByLocatorId(graph, locatorId)
|
||||
|
||||
if (!litegraphNode?.isSubgraphNode() || !('subgraph' in litegraphNode)) {
|
||||
console.warn('LGraphNode: Node is not a valid subgraph node', litegraphNode)
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = app.canvas
|
||||
if (!canvas || typeof canvas.openSubgraph !== 'function') {
|
||||
console.warn('LGraphNode: Canvas or openSubgraph method not available')
|
||||
return
|
||||
}
|
||||
|
||||
canvas.openSubgraph(litegraphNode.subgraph)
|
||||
}
|
||||
|
||||
const nodeOutputs = useNodeOutputStore()
|
||||
|
||||
const nodeOutputLocatorId = computed(() =>
|
||||
nodeData.subgraphId ? `${nodeData.subgraphId}:${nodeData.id}` : nodeData.id
|
||||
)
|
||||
const nodeImageUrls = computed(() => {
|
||||
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
|
||||
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 []
|
||||
}
|
||||
|
||||
const node = getNodeByLocatorId(rootGraph, locatorId)
|
||||
|
||||
if (node && newOutputs?.images?.length) {
|
||||
const urls = nodeOutputs.getNodeImageUrls(node)
|
||||
if (urls) {
|
||||
return urls
|
||||
}
|
||||
}
|
||||
// Clear URLs if no outputs or no images
|
||||
return []
|
||||
})
|
||||
|
||||
const nodeContainerRef = ref()
|
||||
provide('tooltipContainer', nodeContainerRef)
|
||||
</script>
|
||||
@@ -0,0 +1,127 @@
|
||||
<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"
|
||||
>
|
||||
<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="flex flex-col gap-4 pb-4">
|
||||
<NodeSlots
|
||||
v-memo="[nodeData.inputs?.length, nodeData.outputs?.length]"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
/>
|
||||
|
||||
<NodeWidgets
|
||||
v-if="nodeData.widgets?.length"
|
||||
v-memo="[nodeData.widgets?.length]"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
/>
|
||||
|
||||
<NodeContent
|
||||
v-if="hasCustomContent"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:image-urls="nodeImageUrls"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { RenderShape } from '@/lib/litegraph/src/litegraph'
|
||||
import NodeContent from '@/renderer/extensions/vueNodes/components/NodeContent.vue'
|
||||
import NodeHeader from '@/renderer/extensions/vueNodes/components/NodeHeader.vue'
|
||||
import NodeSlots from '@/renderer/extensions/vueNodes/components/NodeSlots.vue'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
|
||||
const { nodeDef } = defineProps<{
|
||||
nodeDef: ComfyNodeDefV2
|
||||
}>()
|
||||
|
||||
const widgetStore = useWidgetStore()
|
||||
|
||||
// Convert nodeDef into VueNodeData
|
||||
const nodeData = computed<VueNodeData>(() => {
|
||||
const widgets = Object.entries(nodeDef.inputs || {})
|
||||
.filter(([_, input]) => widgetStore.inputIsWidget(input))
|
||||
.map(([name, input]) => ({
|
||||
name,
|
||||
type: input.widgetType || input.type,
|
||||
value:
|
||||
input.default !== undefined
|
||||
? input.default
|
||||
: input.type === 'COMBO' &&
|
||||
Array.isArray(input.options) &&
|
||||
input.options.length > 0
|
||||
? input.options[0]
|
||||
: undefined,
|
||||
options: {
|
||||
...input,
|
||||
hidden: input.hidden,
|
||||
advanced: input.advanced,
|
||||
values: input.type === 'COMBO' ? input.options : undefined // For combo widgets
|
||||
}
|
||||
}))
|
||||
|
||||
const inputs: INodeInputSlot[] = Object.entries(nodeDef.inputs || {})
|
||||
.filter(([_, input]) => !widgetStore.inputIsWidget(input))
|
||||
.map(([name, input]) => ({
|
||||
name,
|
||||
type: input.type,
|
||||
shape: input.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
boundingRect: [0, 0, 0, 0],
|
||||
link: null
|
||||
}))
|
||||
|
||||
const outputs: INodeOutputSlot[] = (nodeDef.outputs || []).map((output) => {
|
||||
if (typeof output === 'string') {
|
||||
return {
|
||||
name: output,
|
||||
type: output,
|
||||
boundingRect: [0, 0, 0, 0],
|
||||
links: []
|
||||
}
|
||||
}
|
||||
return {
|
||||
...output,
|
||||
boundingRect: [0, 0, 0, 0],
|
||||
links: []
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
id: `preview-${nodeDef.name}`,
|
||||
title: nodeDef.display_name || nodeDef.name,
|
||||
type: nodeDef.name,
|
||||
mode: 0, // Normal mode
|
||||
selected: false,
|
||||
executing: false,
|
||||
widgets,
|
||||
inputs,
|
||||
outputs,
|
||||
flags: {
|
||||
collapsed: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const readonly = true
|
||||
const hasCustomContent = false
|
||||
const nodeImageUrls = ['']
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
class="lod-fallback absolute inset-0 w-full h-full bg-zinc-300 dark-theme:bg-zinc-800"
|
||||
></div>
|
||||
</template>
|
||||
52
src/renderer/extensions/vueNodes/components/NodeContent.vue
Normal file
52
src/renderer/extensions/vueNodes/components/NodeContent.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
{{ $t('Node Content Error') }}
|
||||
</div>
|
||||
<div v-else class="lg-node-content">
|
||||
<!-- Default slot for custom content -->
|
||||
<slot>
|
||||
<ImagePreview
|
||||
v-if="hasImages"
|
||||
:image-urls="props.imageUrls || []"
|
||||
:node-id="nodeId"
|
||||
class="mt-2"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 ImagePreview from './ImagePreview.vue'
|
||||
|
||||
interface NodeContentProps {
|
||||
node?: LGraphNode // For backwards compatibility
|
||||
nodeData?: VueNodeData // New clean data structure
|
||||
readonly?: boolean
|
||||
imageUrls?: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<NodeContentProps>()
|
||||
|
||||
const hasImages = computed(() => props.imageUrls && props.imageUrls.length > 0)
|
||||
|
||||
// Get node ID from nodeData or node prop
|
||||
const nodeId = computed(() => {
|
||||
return props.nodeData?.id?.toString() || props.node?.id?.toString()
|
||||
})
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
toastErrorHandler(error)
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
272
src/renderer/extensions/vueNodes/components/NodeHeader.test.ts
Normal file
272
src/renderer/extensions/vueNodes/components/NodeHeader.test.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
|
||||
const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
type: 'KSampler',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
widgets: [],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
flags: { collapsed: false },
|
||||
...overrides
|
||||
})
|
||||
|
||||
const setupMockStores = () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
// Mock tooltip delay setting
|
||||
vi.spyOn(settingStore, 'get').mockImplementation(
|
||||
<K extends keyof Settings>(key: K): Settings[K] => {
|
||||
switch (key) {
|
||||
case 'Comfy.EnableTooltips':
|
||||
return true as Settings[K]
|
||||
case 'LiteGraph.Node.TooltipDelay':
|
||||
return 500 as Settings[K]
|
||||
default:
|
||||
return undefined as Settings[K]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Mock node definition store
|
||||
const baseMockNodeDef: ComfyNodeDef = {
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
category: 'sampling',
|
||||
python_module: 'test_module',
|
||||
description: 'Advanced sampling node for diffusion models',
|
||||
input: {
|
||||
required: {
|
||||
model: ['MODEL', {}],
|
||||
positive: ['CONDITIONING', {}],
|
||||
negative: ['CONDITIONING', {}]
|
||||
},
|
||||
optional: {},
|
||||
hidden: {}
|
||||
},
|
||||
output: ['LATENT'],
|
||||
output_is_list: [false],
|
||||
output_name: ['samples'],
|
||||
output_node: false,
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
}
|
||||
|
||||
const mockNodeDef = new ComfyNodeDefImpl(baseMockNodeDef)
|
||||
|
||||
vi.spyOn(nodeDefStore, 'nodeDefsByName', 'get').mockReturnValue({
|
||||
KSampler: mockNodeDef
|
||||
})
|
||||
|
||||
return { settingStore, nodeDefStore, pinia }
|
||||
}
|
||||
|
||||
const createMountConfig = () => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
const { pinia } = setupMockStores()
|
||||
|
||||
return {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n, pinia],
|
||||
components: { InputText },
|
||||
directives: {
|
||||
tooltip: {
|
||||
mounted: vi.fn(),
|
||||
updated: vi.fn(),
|
||||
unmounted: vi.fn()
|
||||
}
|
||||
},
|
||||
provide: {
|
||||
tooltipContainer: { value: document.createElement('div') }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mountHeader = (
|
||||
props?: Partial<InstanceType<typeof NodeHeader>['$props']>
|
||||
) => {
|
||||
const config = createMountConfig()
|
||||
|
||||
return mount(NodeHeader, {
|
||||
...config,
|
||||
props: {
|
||||
nodeData: makeNodeData(),
|
||||
readonly: false,
|
||||
collapsed: false,
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('NodeHeader.vue', () => {
|
||||
it('emits collapse when collapse button is clicked', async () => {
|
||||
const wrapper = mountHeader()
|
||||
const btn = wrapper.get('[data-testid="node-collapse-button"]')
|
||||
await btn.trigger('click')
|
||||
expect(wrapper.emitted('collapse')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the current node title and updates when prop changes', async () => {
|
||||
const wrapper = mountHeader({
|
||||
nodeData: makeNodeData({ title: 'Original' })
|
||||
})
|
||||
// Title visible via EditableText in view mode
|
||||
expect(wrapper.get('[data-testid="node-title"]').text()).toContain(
|
||||
'Original'
|
||||
)
|
||||
|
||||
// Update prop title; should sync displayTitle
|
||||
await wrapper.setProps({ nodeData: makeNodeData({ title: 'Updated' }) })
|
||||
expect(wrapper.get('[data-testid="node-title"]').text()).toContain(
|
||||
'Updated'
|
||||
)
|
||||
})
|
||||
|
||||
it('allows renaming via double click and emits update:title on confirm', async () => {
|
||||
const wrapper = mountHeader({ nodeData: makeNodeData({ title: 'Start' }) })
|
||||
|
||||
// Enter edit mode
|
||||
await wrapper.get('[data-testid="node-header-1"]').trigger('dblclick')
|
||||
|
||||
// Edit and confirm (EditableText uses blur or enter to emit)
|
||||
const input = wrapper.get('[data-testid="node-title-input"]')
|
||||
await input.setValue('My Custom Sampler')
|
||||
await input.trigger('keyup.enter')
|
||||
await input.trigger('blur')
|
||||
|
||||
// NodeHeader should emit update:title with trimmed value
|
||||
const e = wrapper.emitted('update:title')
|
||||
expect(e).toBeTruthy()
|
||||
expect(e?.[0]).toEqual(['My Custom Sampler'])
|
||||
})
|
||||
|
||||
it('cancels rename on escape and keeps previous title', async () => {
|
||||
const wrapper = mountHeader({ nodeData: makeNodeData({ title: 'KeepMe' }) })
|
||||
|
||||
await wrapper.get('[data-testid="node-header-1"]').trigger('dblclick')
|
||||
const input = wrapper.get('[data-testid="node-title-input"]')
|
||||
await input.setValue('Should Not Save')
|
||||
await input.trigger('keyup.escape')
|
||||
|
||||
// Should not emit update:title
|
||||
expect(wrapper.emitted('update:title')).toBeFalsy()
|
||||
|
||||
// Title remains the original
|
||||
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')
|
||||
|
||||
await wrapper.setProps({ collapsed: true })
|
||||
const collapsedIcon = wrapper.get('i')
|
||||
expect(collapsedIcon.classes()).toContain('pi-chevron-right')
|
||||
})
|
||||
|
||||
describe('Tooltips', () => {
|
||||
it('applies tooltip directive to node title with correct configuration', () => {
|
||||
const wrapper = mountHeader({
|
||||
nodeData: makeNodeData({ type: 'KSampler' })
|
||||
})
|
||||
|
||||
const titleElement = wrapper.find('[data-testid="node-title"]')
|
||||
expect(titleElement.exists()).toBe(true)
|
||||
|
||||
// Check that v-tooltip directive was applied
|
||||
const directive = wrapper.vm.$el.querySelector(
|
||||
'[data-testid="node-title"]'
|
||||
)
|
||||
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' })
|
||||
})
|
||||
|
||||
// Enter edit mode
|
||||
await wrapper.get('[data-testid="node-header-1"]').trigger('dblclick')
|
||||
|
||||
// Tooltip should be disabled during editing
|
||||
const titleElement = wrapper.find('[data-testid="node-title"]')
|
||||
expect(titleElement.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('creates tooltip configuration when component mounts', () => {
|
||||
const wrapper = mountHeader({
|
||||
nodeData: makeNodeData({ type: 'KSampler' })
|
||||
})
|
||||
|
||||
// Verify tooltip directive is applied to the title element
|
||||
const titleElement = wrapper.find('[data-testid="node-title"]')
|
||||
expect(titleElement.exists()).toBe(true)
|
||||
|
||||
// The tooltip composable should be initialized
|
||||
expect(wrapper.vm).toBeDefined()
|
||||
})
|
||||
|
||||
it('uses tooltip container from provide/inject', () => {
|
||||
const wrapper = mountHeader({
|
||||
nodeData: makeNodeData({ type: 'KSampler' })
|
||||
})
|
||||
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
// Container should be provided through inject
|
||||
const titleElement = wrapper.find('[data-testid="node-title"]')
|
||||
expect(titleElement.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
215
src/renderer/extensions/vueNodes/components/NodeHeader.vue
Normal file
215
src/renderer/extensions/vueNodes/components/NodeHeader.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-4 text-red-500 text-sm">
|
||||
{{ $t('Node Header Error') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="lg-node-header p-4 rounded-t-2xl w-full cursor-move"
|
||||
:style="headerStyle"
|
||||
:data-testid="`node-header-${nodeData?.id || ''}`"
|
||||
@dblclick="handleDoubleClick"
|
||||
>
|
||||
<div class="flex items-center justify-between 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>
|
||||
|
||||
<!-- Node Title -->
|
||||
<div
|
||||
v-tooltip.top="tooltipConfig"
|
||||
class="text-sm font-bold truncate flex-1 lod-toggle flex items-center gap-2"
|
||||
data-testid="node-title"
|
||||
>
|
||||
<EditableText
|
||||
:model-value="displayTitle"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ 'data-testid': 'node-title-input' }"
|
||||
@edit="handleTitleEdit"
|
||||
@cancel="handleTitleCancel"
|
||||
/>
|
||||
<i-lucide:pin
|
||||
v-if="isPinned"
|
||||
class="w-5 h-5 text-stone-200 dark-theme:text-slate-300"
|
||||
data-testid="node-pin-indicator"
|
||||
/>
|
||||
</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 IconButton from '@/components/button/IconButton.vue'
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { st } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import {
|
||||
getLocatorIdFromNodeData,
|
||||
getNodeByLocatorId
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
|
||||
interface NodeHeaderProps {
|
||||
nodeData?: VueNodeData
|
||||
readonly?: boolean
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
const { nodeData, readonly, collapsed } = defineProps<NodeHeaderProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
collapse: []
|
||||
'update:title': [newTitle: string]
|
||||
'enter-subgraph': []
|
||||
}>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
toastErrorHandler(error)
|
||||
return false
|
||||
})
|
||||
|
||||
// Editing state
|
||||
const isEditing = ref(false)
|
||||
|
||||
const tooltipContainer =
|
||||
inject<Ref<HTMLElement | undefined>>('tooltipContainer')
|
||||
const { getNodeDescription, createTooltipConfig } = useNodeTooltips(
|
||||
nodeData?.type || '',
|
||||
tooltipContainer
|
||||
)
|
||||
|
||||
const tooltipConfig = computed(() => {
|
||||
if (readonly || isEditing.value) {
|
||||
return { value: '', disabled: true }
|
||||
}
|
||||
const description = getNodeDescription.value
|
||||
return createTooltipConfig(description)
|
||||
})
|
||||
|
||||
const headerStyle = computed(() => {
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
|
||||
const opacity = useSettingStore().get('Comfy.Node.Opacity') ?? 1
|
||||
|
||||
if (!nodeData?.color) {
|
||||
return { backgroundColor: '', opacity }
|
||||
}
|
||||
|
||||
const headerColor = applyLightThemeColor(
|
||||
nodeData.color,
|
||||
Boolean(colorPaletteStore.completedActivePalette.light_theme)
|
||||
)
|
||||
|
||||
return { backgroundColor: headerColor, opacity }
|
||||
})
|
||||
|
||||
const resolveTitle = (info: VueNodeData | undefined) => {
|
||||
const title = (info?.title ?? '').trim()
|
||||
if (title.length > 0) return title
|
||||
|
||||
const nodeType = (info?.type ?? '').trim() || 'Untitled'
|
||||
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
|
||||
return st(key, nodeType)
|
||||
}
|
||||
|
||||
// Local state for title to provide immediate feedback
|
||||
const displayTitle = ref(resolveTitle(nodeData))
|
||||
|
||||
// Watch for external changes to the node title or type
|
||||
watch(
|
||||
() => [nodeData?.title, nodeData?.type] as const,
|
||||
() => {
|
||||
const next = resolveTitle(nodeData)
|
||||
if (next !== displayTitle.value) {
|
||||
displayTitle.value = next
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const isPinned = computed(() => Boolean(nodeData?.flags?.pinned))
|
||||
|
||||
// Subgraph detection
|
||||
const isSubgraphNode = computed(() => {
|
||||
if (!nodeData?.id) return false
|
||||
|
||||
// Get the underlying LiteGraph node
|
||||
const graph = app.graph?.rootGraph || app.graph
|
||||
if (!graph) return false
|
||||
|
||||
const locatorId = getLocatorIdFromNodeData(nodeData)
|
||||
|
||||
const litegraphNode = getNodeByLocatorId(graph, locatorId)
|
||||
|
||||
// Use the official type guard method
|
||||
return litegraphNode?.isSubgraphNode() ?? false
|
||||
})
|
||||
|
||||
// Event handlers
|
||||
const handleCollapse = () => {
|
||||
emit('collapse')
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
if (!readonly) {
|
||||
isEditing.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleTitleEdit = (newTitle: string) => {
|
||||
isEditing.value = false
|
||||
const trimmedTitle = newTitle.trim()
|
||||
if (trimmedTitle && trimmedTitle !== displayTitle.value) {
|
||||
// Emit for litegraph sync
|
||||
emit('update:title', trimmedTitle)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTitleCancel = () => {
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const handleEnterSubgraph = () => {
|
||||
emit('enter-subgraph')
|
||||
}
|
||||
</script>
|
||||
210
src/renderer/extensions/vueNodes/components/NodeSlots.test.ts
Normal file
210
src/renderer/extensions/vueNodes/components/NodeSlots.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type PropType, defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import NodeSlots from './NodeSlots.vue'
|
||||
|
||||
const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
|
||||
id: '123',
|
||||
title: 'Test Node',
|
||||
type: 'TestType',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
flags: { collapsed: false },
|
||||
...overrides
|
||||
})
|
||||
|
||||
// Explicit stubs to capture props for assertions
|
||||
interface StubSlotData {
|
||||
name?: string
|
||||
type?: string
|
||||
boundingRect?: [number, number, number, number]
|
||||
}
|
||||
|
||||
const InputSlotStub = defineComponent({
|
||||
name: 'InputSlot',
|
||||
props: {
|
||||
slotData: { type: Object as PropType<StubSlotData>, required: true },
|
||||
nodeId: { type: String, required: false, default: '' },
|
||||
index: { type: Number, required: true },
|
||||
readonly: { type: Boolean, required: false, default: false }
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
class="stub-input-slot"
|
||||
:data-index="index"
|
||||
:data-name="slotData && slotData.name ? slotData.name : ''"
|
||||
:data-type="slotData && slotData.type ? slotData.type : ''"
|
||||
:data-node-id="nodeId"
|
||||
:data-readonly="readonly ? 'true' : 'false'"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
const OutputSlotStub = defineComponent({
|
||||
name: 'OutputSlot',
|
||||
props: {
|
||||
slotData: { type: Object as PropType<StubSlotData>, required: true },
|
||||
nodeId: { type: String, required: false, default: '' },
|
||||
index: { type: Number, required: true },
|
||||
readonly: { type: Boolean, required: false, default: false }
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
class="stub-output-slot"
|
||||
:data-index="index"
|
||||
:data-name="slotData && slotData.name ? slotData.name : ''"
|
||||
:data-type="slotData && slotData.type ? slotData.type : ''"
|
||||
:data-node-id="nodeId"
|
||||
:data-readonly="readonly ? 'true' : 'false'"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
const mountSlots = (nodeData: VueNodeData, readonly = false) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
return mount(NodeSlots, {
|
||||
global: {
|
||||
plugins: [i18n, createPinia()],
|
||||
stubs: {
|
||||
InputSlot: InputSlotStub,
|
||||
OutputSlot: OutputSlotStub
|
||||
}
|
||||
},
|
||||
props: { nodeData, readonly }
|
||||
})
|
||||
}
|
||||
|
||||
describe('NodeSlots.vue', () => {
|
||||
it('filters out inputs with widget property and maps indexes correctly', () => {
|
||||
// Two inputs without widgets (object and string) and one with widget (filtered)
|
||||
const inputObjNoWidget = {
|
||||
name: 'objNoWidget',
|
||||
type: 'number',
|
||||
boundingRect: new Float32Array([0, 0, 0, 0]),
|
||||
link: null
|
||||
}
|
||||
const inputObjWithWidget = {
|
||||
name: 'objWithWidget',
|
||||
type: 'number',
|
||||
boundingRect: new Float32Array([0, 0, 0, 0]),
|
||||
widget: { name: 'objWithWidget' },
|
||||
link: null
|
||||
}
|
||||
const inputs: INodeInputSlot[] = [inputObjNoWidget, inputObjWithWidget]
|
||||
|
||||
const wrapper = mountSlots(makeNodeData({ inputs }))
|
||||
|
||||
const inputEls = wrapper
|
||||
.findAll('.stub-input-slot')
|
||||
.map((w) => w.element as HTMLElement)
|
||||
// Should filter out the widget-backed input; expect 2 inputs rendered
|
||||
expect(inputEls.length).toBe(2)
|
||||
|
||||
// Verify expected tuple of {index, name, nodeId}
|
||||
const info = inputEls.map((el) => ({
|
||||
index: Number(el.dataset.index),
|
||||
name: el.dataset.name ?? '',
|
||||
nodeId: el.dataset.nodeId ?? '',
|
||||
type: el.dataset.type ?? '',
|
||||
readonly: el.dataset.readonly === 'true'
|
||||
}))
|
||||
expect(info).toEqual([
|
||||
{
|
||||
index: 0,
|
||||
name: 'objNoWidget',
|
||||
nodeId: '123',
|
||||
type: 'number',
|
||||
readonly: false
|
||||
},
|
||||
// string input is converted to object with default type 'any'
|
||||
{
|
||||
index: 1,
|
||||
name: 'stringInput',
|
||||
nodeId: '123',
|
||||
type: 'any',
|
||||
readonly: false
|
||||
}
|
||||
])
|
||||
|
||||
// Ensure widget-backed input was indeed filtered out
|
||||
expect(wrapper.find('[data-name="objWithWidget"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('maps outputs and passes correct indexes', () => {
|
||||
const outputObj = {
|
||||
name: 'outA',
|
||||
type: 'any',
|
||||
boundingRect: new Float32Array([0, 0, 0, 0]),
|
||||
links: []
|
||||
}
|
||||
const outputObjB = {
|
||||
name: 'outB',
|
||||
type: 'any',
|
||||
boundingRect: new Float32Array([0, 0, 0, 0]),
|
||||
links: []
|
||||
}
|
||||
const outputs: INodeOutputSlot[] = [outputObj, outputObjB]
|
||||
|
||||
const wrapper = mountSlots(makeNodeData({ outputs }))
|
||||
const outputEls = wrapper
|
||||
.findAll('.stub-output-slot')
|
||||
.map((w) => w.element as HTMLElement)
|
||||
|
||||
expect(outputEls.length).toBe(2)
|
||||
const outInfo = outputEls.map((el) => ({
|
||||
index: Number(el.dataset.index),
|
||||
name: el.dataset.name ?? '',
|
||||
nodeId: el.dataset.nodeId ?? '',
|
||||
type: el.dataset.type ?? '',
|
||||
readonly: el.dataset.readonly === 'true'
|
||||
}))
|
||||
expect(outInfo).toEqual([
|
||||
{ index: 0, name: 'outA', nodeId: '123', type: 'any', readonly: false },
|
||||
// string output mapped to object with type 'any'
|
||||
{ index: 1, name: 'outB', nodeId: '123', type: 'any', readonly: false }
|
||||
])
|
||||
})
|
||||
|
||||
it('renders nothing when there are no inputs/outputs', () => {
|
||||
const wrapper = mountSlots(makeNodeData({ inputs: [], outputs: [] }))
|
||||
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')
|
||||
}
|
||||
})
|
||||
})
|
||||
110
src/renderer/extensions/vueNodes/components/NodeSlots.vue
Normal file
110
src/renderer/extensions/vueNodes/components/NodeSlots.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
{{ $t('Node Slots Error') }}
|
||||
</div>
|
||||
<div v-else class="lg-node-slots flex justify-between">
|
||||
<div v-if="filteredInputs.length" class="flex flex-col gap-1">
|
||||
<InputSlot
|
||||
v-for="(input, index) in filteredInputs"
|
||||
:key="`input-${index}`"
|
||||
:slot-data="input"
|
||||
: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">
|
||||
<OutputSlot
|
||||
v-for="(output, index) in filteredOutputs"
|
||||
:key="`output-${index}`"
|
||||
:slot-data="output"
|
||||
:node-type="nodeData?.type || ''"
|
||||
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
|
||||
:index="index"
|
||||
:readonly="readonly"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
|
||||
import { isSlotObject } from '@/utils/typeGuardUtil'
|
||||
|
||||
import InputSlot from './InputSlot.vue'
|
||||
import OutputSlot from './OutputSlot.vue'
|
||||
|
||||
interface NodeSlotsProps {
|
||||
nodeData?: VueNodeData
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const { nodeData = null, readonly } = defineProps<NodeSlotsProps>()
|
||||
|
||||
// Filter out input slots that have corresponding widgets
|
||||
const filteredInputs = computed(() => {
|
||||
if (!nodeData?.inputs) return []
|
||||
|
||||
return nodeData.inputs
|
||||
.filter((input) => {
|
||||
// Check if this slot has a widget property (indicating it has a corresponding widget)
|
||||
if (isSlotObject(input) && 'widget' in input && input.widget) {
|
||||
// This slot has a widget, so we should not display it separately
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map((input) =>
|
||||
isSlotObject(input)
|
||||
? input
|
||||
: ({
|
||||
name: typeof input === 'string' ? input : '',
|
||||
type: 'any',
|
||||
boundingRect: [0, 0, 0, 0] as [number, number, number, number]
|
||||
} as INodeSlot)
|
||||
)
|
||||
})
|
||||
|
||||
// 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 = (
|
||||
input: INodeSlot,
|
||||
filteredIndex: number
|
||||
): number => {
|
||||
if (!nodeData?.inputs) return filteredIndex
|
||||
|
||||
// Find the actual index in the unfiltered inputs array
|
||||
const actualIndex = nodeData.inputs.findIndex((i) => i === input)
|
||||
return actualIndex !== -1 ? actualIndex : filteredIndex
|
||||
}
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
toastErrorHandler(error)
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
181
src/renderer/extensions/vueNodes/components/NodeWidgets.vue
Normal file
181
src/renderer/extensions/vueNodes/components/NodeWidgets.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
{{ $t('Node Widgets Error') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'lg-node-widgets flex flex-col gap-2 pr-4',
|
||||
shouldHandleNodePointerEvents
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none'
|
||||
)
|
||||
"
|
||||
@pointerdown.stop="handleWidgetPointerEvent"
|
||||
@pointermove.stop="handleWidgetPointerEvent"
|
||||
@pointerup.stop="handleWidgetPointerEvent"
|
||||
>
|
||||
<div
|
||||
v-for="(widget, index) in processedWidgets"
|
||||
:key="`widget-${index}-${widget.name}`"
|
||||
class="lg-widget-container flex items-center group"
|
||||
>
|
||||
<!-- Widget Input Slot Dot -->
|
||||
|
||||
<div
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity duration-150"
|
||||
>
|
||||
<InputSlot
|
||||
:slot-data="{
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
boundingRect: [0, 0, 0, 0]
|
||||
}"
|
||||
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
|
||||
:index="getWidgetInputIndex(widget)"
|
||||
:readonly="readonly"
|
||||
:dot-only="true"
|
||||
/>
|
||||
</div>
|
||||
<!-- Widget Component -->
|
||||
<component
|
||||
:is="widget.vueComponent"
|
||||
v-tooltip.left="widget.tooltipConfig"
|
||||
:widget="widget.simplified"
|
||||
:model-value="widget.value"
|
||||
:readonly="readonly"
|
||||
class="flex-1"
|
||||
@update:model-value="widget.updateHandler"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type Ref, computed, inject, onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
SafeWidgetData,
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
// Import widget components directly
|
||||
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
|
||||
import {
|
||||
getComponent,
|
||||
shouldRenderAsVue
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import InputSlot from './InputSlot.vue'
|
||||
|
||||
interface NodeWidgetsProps {
|
||||
nodeData?: VueNodeData
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const { nodeData, readonly } = defineProps<NodeWidgetsProps>()
|
||||
|
||||
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
|
||||
useCanvasInteractions()
|
||||
const handleWidgetPointerEvent = (event: PointerEvent) => {
|
||||
if (!shouldHandleNodePointerEvents.value) {
|
||||
forwardEventToCanvas(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
toastErrorHandler(error)
|
||||
return false
|
||||
})
|
||||
|
||||
const nodeType = computed(() => nodeData?.type || '')
|
||||
const tooltipContainer =
|
||||
inject<Ref<HTMLElement | undefined>>('tooltipContainer')
|
||||
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
|
||||
nodeType.value,
|
||||
tooltipContainer
|
||||
)
|
||||
|
||||
interface ProcessedWidget {
|
||||
name: string
|
||||
type: string
|
||||
vueComponent: any
|
||||
simplified: SimplifiedWidget
|
||||
value: WidgetValue
|
||||
updateHandler: (value: unknown) => void
|
||||
tooltipConfig: any
|
||||
}
|
||||
|
||||
const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
if (!nodeData?.widgets) return []
|
||||
|
||||
const widgets = nodeData.widgets as SafeWidgetData[]
|
||||
const result: ProcessedWidget[] = []
|
||||
|
||||
for (const widget of widgets) {
|
||||
if (widget.options?.hidden) continue
|
||||
if (widget.options?.canvasOnly) continue
|
||||
if (!widget.type) continue
|
||||
if (!shouldRenderAsVue(widget)) continue
|
||||
|
||||
const vueComponent = getComponent(widget.type) || WidgetInputText
|
||||
|
||||
const simplified: SimplifiedWidget = {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: widget.value,
|
||||
label: widget.label,
|
||||
options: widget.options,
|
||||
callback: widget.callback,
|
||||
spec: widget.spec
|
||||
}
|
||||
|
||||
const updateHandler = (value: unknown) => {
|
||||
if (widget.callback) {
|
||||
widget.callback(value)
|
||||
}
|
||||
}
|
||||
|
||||
const tooltipText = getWidgetTooltip(widget)
|
||||
const tooltipConfig = createTooltipConfig(tooltipText)
|
||||
|
||||
result.push({
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
vueComponent,
|
||||
simplified,
|
||||
value: widget.value,
|
||||
updateHandler,
|
||||
tooltipConfig
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// TODO: Refactor to avoid O(n) lookup - consider storing input index on widget creation
|
||||
// or restructuring data model to unify widgets and inputs
|
||||
// Map a widget to its corresponding input slot index
|
||||
const getWidgetInputIndex = (widget: ProcessedWidget): number => {
|
||||
const inputs = nodeData?.inputs
|
||||
if (!inputs) return 0
|
||||
|
||||
const idx = inputs.findIndex((input: any) => {
|
||||
if (!input || typeof input !== 'object') return false
|
||||
if (!('name' in input && 'type' in input)) return false
|
||||
return 'widget' in input && input.widget?.name === widget.name
|
||||
})
|
||||
return idx >= 0 ? idx : 0
|
||||
}
|
||||
</script>
|
||||
126
src/renderer/extensions/vueNodes/components/OutputSlot.vue
Normal file
126
src/renderer/extensions/vueNodes/components/OutputSlot.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs">⚠️</div>
|
||||
<div v-else v-tooltip.right="tooltipConfig" :class="slotWrapperClass">
|
||||
<div class="relative">
|
||||
<!-- Slot Name -->
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200 lod-toggle"
|
||||
>
|
||||
{{ slotData.localized_name || slotData.name || `Output ${index}` }}
|
||||
</span>
|
||||
<LODFallback />
|
||||
</div>
|
||||
<!-- Connection Dot -->
|
||||
<SlotConnectionDot
|
||||
ref="connectionDotRef"
|
||||
:color="slotColor"
|
||||
class="translate-x-1/2"
|
||||
v-on="readonly ? {} : { pointerdown: onPointerDown }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type ComponentPublicInstance,
|
||||
type Ref,
|
||||
computed,
|
||||
inject,
|
||||
onErrorCaptured,
|
||||
ref,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { getSlotColor } from '@/constants/slotColors'
|
||||
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
interface OutputSlotProps {
|
||||
nodeType?: string
|
||||
nodeId?: string
|
||||
slotData: INodeSlot
|
||||
index: number
|
||||
connected?: boolean
|
||||
compatible?: boolean
|
||||
readonly?: boolean
|
||||
dotOnly?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<OutputSlotProps>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
const tooltipContainer =
|
||||
inject<Ref<HTMLElement | undefined>>('tooltipContainer')
|
||||
const { getOutputSlotTooltip, createTooltipConfig } = useNodeTooltips(
|
||||
props.nodeType || '',
|
||||
tooltipContainer
|
||||
)
|
||||
|
||||
const tooltipConfig = computed(() => {
|
||||
const slotName = props.slotData.name || ''
|
||||
const tooltipText = getOutputSlotTooltip(props.index)
|
||||
const fallbackText = tooltipText || `Output: ${slotName}`
|
||||
return createTooltipConfig(fallbackText)
|
||||
})
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
toastErrorHandler(error)
|
||||
return false
|
||||
})
|
||||
|
||||
// Get slot color based on type
|
||||
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',
|
||||
props.dotOnly
|
||||
? 'lg-slot--dot-only justify-center'
|
||||
: 'pl-6 hover:bg-black/5 hover:dark:bg-white/5',
|
||||
{
|
||||
'lg-slot--connected': props.connected,
|
||||
'lg-slot--compatible': props.compatible
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const connectionDotRef = ref<ComponentPublicInstance<{
|
||||
slotElRef: HTMLElement | undefined
|
||||
}> | null>(null)
|
||||
const slotElRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Watch for when the child component's ref becomes available
|
||||
// Vue automatically unwraps the Ref when exposing it
|
||||
watchEffect(() => {
|
||||
const el = connectionDotRef.value?.slotElRef
|
||||
slotElRef.value = el || null
|
||||
})
|
||||
|
||||
useSlotElementTracking({
|
||||
nodeId: props.nodeId ?? '',
|
||||
index: props.index,
|
||||
type: 'output',
|
||||
element: slotElRef
|
||||
})
|
||||
|
||||
const { onPointerDown } = useSlotLinkInteraction({
|
||||
nodeId: props.nodeId ?? '',
|
||||
index: props.index,
|
||||
type: 'output',
|
||||
readonly: props.readonly
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import { type ClassValue, cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
color?: string
|
||||
multi?: boolean
|
||||
class?: ClassValue
|
||||
}>()
|
||||
|
||||
const slotElRef = useTemplateRef('slot-el')
|
||||
|
||||
defineExpose({
|
||||
slotElRef
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn('size-6 flex items-center justify-center group/slot', props.class)
|
||||
"
|
||||
>
|
||||
<div
|
||||
ref="slot-el"
|
||||
class="slot-dot"
|
||||
:style="{ backgroundColor: color }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-[#5B5E7D] 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',
|
||||
multi ? 'w-3 h-6' : 'size-3'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,141 @@
|
||||
# ComfyUI Widget LOD System: Architecture and Implementation
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The ComfyUI widget Level of Detail (LOD) system has evolved from a reactive, Vue-based approach to a CSS-driven, non-reactive implementation. This architectural shift was driven by performance requirements at scale (300-500+ nodes) and a deeper understanding of browser rendering pipelines. The current system prioritizes consistent performance over granular control, leveraging CSS visibility rules rather than component mounting/unmounting.
|
||||
|
||||
## The Two Approaches: Reactive vs. Static LOD
|
||||
|
||||
### Approach 1: Reactive LOD (Original Design)
|
||||
|
||||
The original design envisioned a system where each widget would reactively respond to zoom level changes, controlling its own detail level through Vue's reactivity system. Widgets would import LOD utilities, compute what to show based on zoom level, and conditionally render elements using `v-if` and `v-show` directives.
|
||||
|
||||
**The promise of this approach was compelling:** widgets could intelligently manage their complexity, progressively revealing detail as users zoomed in, much like how mapping applications work. Developers would have fine-grained control over performance optimization.
|
||||
|
||||
### Approach 2: Static LOD with CSS (Current Implementation)
|
||||
|
||||
The implemented system takes a fundamentally different approach. All widget content is loaded and remains in the DOM at all times. Visual simplification happens through CSS rules, primarily using `visibility: hidden` and simplified visual representations (gray rectangles) at distant zoom levels. No reactive updates occur when zoom changes—only CSS rules apply differently.
|
||||
|
||||
**This approach seems counterintuitive at first:** aren't we wasting resources by keeping everything loaded? The answer reveals a deeper truth about modern browser rendering.
|
||||
|
||||
## The GPU Texture Bottleneck
|
||||
|
||||
The key insight driving the current architecture comes from understanding how browsers handle CSS transforms:
|
||||
|
||||
When you apply a CSS transform to a parent element (the "transformpane" in ComfyUI's case), the browser promotes that entire subtree to a compositor layer. This creates a single GPU texture containing all the transformed content. Here's where traditional performance intuitions break down:
|
||||
|
||||
### Traditional Assumption
|
||||
|
||||
"If we render less content, we get better performance. Therefore, hiding complex widgets should improve zoom/pan performance."
|
||||
|
||||
### Actual Browser Behavior
|
||||
|
||||
When all nodes are children of a single transformed parent:
|
||||
|
||||
1. The browser creates one large GPU texture for the entire node graph
|
||||
2. The texture dimensions are determined by the bounding box of all content
|
||||
3. Whether individual pixels are simple (solid rectangles) or complex (detailed widgets) has minimal impact
|
||||
4. The performance bottleneck is the texture size itself, not the complexity of rasterization
|
||||
|
||||
This means that even if we reduce every node to a simple gray rectangle, we're still paying the cost of a massive GPU texture when viewing hundreds of nodes simultaneously. The texture dimensions remain the same whether it contains simple or complex content.
|
||||
|
||||
## Two Distinct Performance Concerns
|
||||
|
||||
The analysis reveals two often-conflated performance considerations that should be understood separately:
|
||||
|
||||
### 1. Rendering Performance
|
||||
|
||||
**Question:** How fast can the browser paint and composite the node graph during interactions?
|
||||
|
||||
**Traditional thinking:** Show less content → render faster
|
||||
**Reality with CSS transforms:** GPU texture size dominates performance, not content complexity
|
||||
|
||||
The CSS transform approach means that zoom, pan, and drag operations are already optimized—they're just transforming an existing GPU texture. The cost is in the initial rasterization and texture upload, which happens regardless of content complexity when texture dimensions are fixed.
|
||||
|
||||
### 2. Memory and Lifecycle Management
|
||||
|
||||
**Question:** How much memory do widget instances consume, and what's the cost of maintaining them?
|
||||
|
||||
This is where unmounting widgets might theoretically help:
|
||||
|
||||
- Complex widgets (3D viewers, chart renderers) might hold significant memory
|
||||
- Event listeners and reactive watchers consume resources
|
||||
- Some widgets might run background processes or animations
|
||||
|
||||
However, the cost of mounting/unmounting hundreds of widgets on zoom changes could create worse performance problems than the memory savings provide. Vue's virtual DOM diffing for hundreds of nodes is expensive, potentially causing noticeable lag during zoom transitions.
|
||||
|
||||
## Design Philosophy and Trade-offs
|
||||
|
||||
The current CSS-based approach makes several deliberate trade-offs:
|
||||
|
||||
### What We Optimize For
|
||||
|
||||
1. **Consistent, predictable performance** - No reactivity means no sudden performance cliffs
|
||||
2. **Smooth zoom/pan interactions** - CSS transforms are hardware-accelerated
|
||||
3. **Simple widget development** - Widget authors don't need to implement LOD logic
|
||||
4. **Reliable state preservation** - Widgets never lose state from unmounting
|
||||
|
||||
### What We Accept
|
||||
|
||||
1. **Higher baseline memory usage** - All widgets remain mounted
|
||||
2. **Less granular control** - Widgets can't optimize their own LOD behavior
|
||||
3. **Potential waste for exotic widgets** - A 3D renderer widget still runs when hidden
|
||||
|
||||
## Open Questions and Future Considerations
|
||||
|
||||
### Should widgets have any LOD control?
|
||||
|
||||
The current system provides a uniform gray rectangle fallback with CSS visibility hiding. This works for 99% of widgets, but raises questions:
|
||||
|
||||
**Scenario:** A widget renders a complex 3D scene or runs expensive computations
|
||||
**Current behavior:** Hidden via CSS but still mounted
|
||||
**Question:** Should such widgets be able to opt into unmounting at distance?
|
||||
|
||||
The challenge is that introducing selective unmounting would require:
|
||||
|
||||
- Maintaining widget state across mount/unmount cycles
|
||||
- Accepting the performance cost of remounting when zooming in
|
||||
- Adding complexity to the widget API
|
||||
|
||||
### Could we reduce GPU texture size?
|
||||
|
||||
Since texture dimensions are the limiting factor, could we:
|
||||
|
||||
- Use multiple compositor layers for different regions (chunk the transformpane)?
|
||||
- Render the nodes using the canvas fallback when 500+ nodes and < 30% zoom.
|
||||
|
||||
These approaches would require significant architectural changes and might introduce their own performance trade-offs.
|
||||
|
||||
### Is there a hybrid approach?
|
||||
|
||||
Could we identify specific threshold scenarios where reactive LOD makes sense?
|
||||
|
||||
- When node count is low (< 50 nodes)
|
||||
- For specifically registered "expensive" widgets
|
||||
- At extreme zoom levels only
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
Given the current architecture, here's how to work within the system:
|
||||
|
||||
### For Widget Developers
|
||||
|
||||
1. **Build widgets assuming they're always visible** - Don't rely on mount/unmount for cleanup
|
||||
2. **Use CSS classes for zoom-responsive styling** - Let CSS handle visual changes
|
||||
3. **Minimize background processing** - Assume your widget is always running
|
||||
4. **Consider requestAnimationFrame throttling** - For animations that won't be visible when zoomed out
|
||||
|
||||
### For System Architects
|
||||
|
||||
1. **Monitor GPU memory usage** - The single texture approach has memory implications
|
||||
2. **Consider viewport culling** - Not rendering off-screen nodes could reduce texture size
|
||||
3. **Profile real-world workflows** - Theoretical performance differs from actual usage patterns
|
||||
4. **Document the architecture clearly** - The non-obvious performance characteristics need explanation
|
||||
|
||||
## Conclusion
|
||||
|
||||
The ComfyUI LOD system represents a pragmatic choice: accepting higher memory usage and less granular control in exchange for predictable performance and implementation simplicity. By understanding that GPU texture dimensions—not rasterization complexity—drive performance in a CSS-transform-based architecture, the team has chosen an approach that may seem counterintuitive but actually aligns with browser rendering realities.
|
||||
|
||||
The system works well for the common case of hundreds of relatively simple widgets. Edge cases involving genuinely expensive widgets may need future consideration, but the current approach provides a solid foundation that avoids the performance pitfalls of reactive LOD at scale.
|
||||
|
||||
The key insight—that showing less doesn't necessarily mean rendering faster when everything lives in a single GPU texture—challenges conventional web performance wisdom and demonstrates the importance of understanding the full rendering pipeline when making architectural decisions.
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Node Event Handlers Composable
|
||||
*
|
||||
* Handles all Vue node interaction events including:
|
||||
* - Node selection with multi-select support
|
||||
* - Node collapse/expand state management
|
||||
* - Node title editing and updates
|
||||
* - Layout mutations for visual feedback
|
||||
* - Integration with LiteGraph canvas selection system
|
||||
*/
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
||||
|
||||
function useNodeEventHandlersIndividual() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const { nodeManager } = useVueNodeLifecycle()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
const { shouldHandleNodePointerEvents } = useCanvasInteractions()
|
||||
|
||||
/**
|
||||
* Handle node selection events
|
||||
* Supports single selection and multi-select with Ctrl/Cmd
|
||||
*/
|
||||
const handleNodeSelect = (
|
||||
event: PointerEvent,
|
||||
nodeData: VueNodeData,
|
||||
wasDragging: boolean
|
||||
) => {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
if (!node) return
|
||||
|
||||
const isMultiSelect = event.ctrlKey || event.metaKey || event.shiftKey
|
||||
|
||||
if (isMultiSelect) {
|
||||
// Ctrl/Cmd+click -> toggle selection
|
||||
if (node.selected) {
|
||||
canvasStore.canvas.deselect(node)
|
||||
} else {
|
||||
canvasStore.canvas.select(node)
|
||||
}
|
||||
} else {
|
||||
// If it wasn't a drag: single-select the node
|
||||
if (!wasDragging) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.canvas.select(node)
|
||||
}
|
||||
// Regular click -> single select
|
||||
}
|
||||
|
||||
// Bring node to front when clicked (similar to LiteGraph behavior)
|
||||
// Skip if node is pinned to avoid unwanted movement
|
||||
if (!node.flags?.pinned) {
|
||||
bringNodeToFront(nodeData.id)
|
||||
}
|
||||
|
||||
// Update canvas selection tracking
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle node collapse/expand state changes
|
||||
* Uses LiteGraph's native collapse method for proper state management
|
||||
*/
|
||||
const handleNodeCollapse = (nodeId: string, collapsed: boolean) => {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
if (!node) return
|
||||
|
||||
// Use LiteGraph's collapse method if the state needs to change
|
||||
const currentCollapsed = node.flags?.collapsed ?? false
|
||||
if (currentCollapsed !== collapsed) {
|
||||
node.collapse()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle node title updates
|
||||
* Updates the title in LiteGraph for persistence across sessions
|
||||
*/
|
||||
const handleNodeTitleUpdate = (nodeId: string, newTitle: string) => {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
if (!node) return
|
||||
|
||||
// Update the node title in LiteGraph for persistence
|
||||
node.title = newTitle
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle node double-click events
|
||||
* Can be used for custom actions like opening node editor
|
||||
*/
|
||||
const handleNodeDoubleClick = (
|
||||
event: PointerEvent,
|
||||
nodeData: VueNodeData
|
||||
) => {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
if (!node) return
|
||||
|
||||
// Prevent default browser behavior
|
||||
event.preventDefault()
|
||||
|
||||
// TODO: add custom double-click behavior here
|
||||
// For now, ensure node is selected
|
||||
if (!node.selected) {
|
||||
handleNodeSelect(event, nodeData, false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle node right-click context menu events
|
||||
* Integrates with LiteGraph's context menu system
|
||||
*/
|
||||
const handleNodeRightClick = (event: PointerEvent, nodeData: VueNodeData) => {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
if (!node) return
|
||||
|
||||
// Prevent default context menu
|
||||
event.preventDefault()
|
||||
|
||||
// Select the node if not already selected
|
||||
if (!node.selected) {
|
||||
handleNodeSelect(event, nodeData, false)
|
||||
}
|
||||
|
||||
// Let LiteGraph handle the context menu
|
||||
// The canvas will handle showing the appropriate context menu
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle node drag start events
|
||||
* Prepares node for dragging and sets appropriate visual state
|
||||
*/
|
||||
const handleNodeDragStart = (event: DragEvent, nodeData: VueNodeData) => {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
if (!node) return
|
||||
|
||||
// Ensure node is selected before dragging
|
||||
if (!node.selected) {
|
||||
// Create a synthetic pointer event for selection
|
||||
const syntheticEvent = new PointerEvent('pointerdown', {
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
bubbles: true
|
||||
})
|
||||
handleNodeSelect(syntheticEvent, nodeData, false)
|
||||
}
|
||||
|
||||
// Set drag data for potential drop operations
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData('application/comfy-node-id', nodeData.id)
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch select multiple nodes
|
||||
* Useful for selection toolbox or area selection
|
||||
*/
|
||||
const selectNodes = (nodeIds: string[], addToSelection = false) => {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
if (!addToSelection) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
}
|
||||
|
||||
nodeIds.forEach((nodeId) => {
|
||||
const node = nodeManager.value?.getNode(nodeId)
|
||||
if (node && canvasStore.canvas) {
|
||||
canvasStore.canvas.select(node)
|
||||
}
|
||||
})
|
||||
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselect specific nodes
|
||||
*/
|
||||
const deselectNodes = (nodeIds: string[]) => {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
nodeIds.forEach((nodeId) => {
|
||||
const node = nodeManager.value?.getNode(nodeId)
|
||||
if (node) {
|
||||
node.selected = false
|
||||
}
|
||||
})
|
||||
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
return {
|
||||
// Core event handlers
|
||||
handleNodeSelect,
|
||||
handleNodeCollapse,
|
||||
handleNodeTitleUpdate,
|
||||
handleNodeDoubleClick,
|
||||
handleNodeRightClick,
|
||||
handleNodeDragStart,
|
||||
|
||||
// Batch operations
|
||||
selectNodes,
|
||||
deselectNodes
|
||||
}
|
||||
}
|
||||
|
||||
export const useNodeEventHandlers = createSharedComposable(
|
||||
useNodeEventHandlersIndividual
|
||||
)
|
||||
@@ -0,0 +1,214 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
|
||||
useCanvasInteractions: () => ({
|
||||
forwardEventToCanvas: vi.fn(),
|
||||
shouldHandleNodePointerEvents: ref(true)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
|
||||
useNodeLayout: () => ({
|
||||
startDrag: vi.fn(),
|
||||
endDrag: vi.fn().mockResolvedValue(undefined),
|
||||
handleDrag: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
isDraggingVueNodes: ref(false)
|
||||
}
|
||||
}))
|
||||
|
||||
const createMockVueNodeData = (
|
||||
overrides: Partial<VueNodeData> = {}
|
||||
): VueNodeData => ({
|
||||
id: 'test-node-123',
|
||||
title: 'Test Node',
|
||||
type: 'TestNodeType',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
...overrides
|
||||
})
|
||||
|
||||
const createPointerEvent = (
|
||||
eventType: string,
|
||||
overrides: Partial<PointerEventInit> = {}
|
||||
): PointerEvent => {
|
||||
return new PointerEvent(eventType, {
|
||||
pointerId: 1,
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
const createMouseEvent = (
|
||||
eventType: string,
|
||||
overrides: Partial<MouseEventInit> = {}
|
||||
): MouseEvent => {
|
||||
return new MouseEvent(eventType, {
|
||||
button: 2, // Right click
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('useNodePointerInteractions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should only start drag on left-click', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
// Right-click should not start drag
|
||||
const rightClickEvent = createPointerEvent('pointerdown', { button: 2 })
|
||||
pointerHandlers.onPointerdown(rightClickEvent)
|
||||
|
||||
expect(mockOnPointerUp).not.toHaveBeenCalled()
|
||||
|
||||
// Left-click should start drag and emit callback
|
||||
const leftClickEvent = createPointerEvent('pointerdown', { button: 0 })
|
||||
pointerHandlers.onPointerdown(leftClickEvent)
|
||||
|
||||
const pointerUpEvent = createPointerEvent('pointerup')
|
||||
pointerHandlers.onPointerup(pointerUpEvent)
|
||||
|
||||
expect(mockOnPointerUp).toHaveBeenCalledWith(
|
||||
pointerUpEvent,
|
||||
mockNodeData,
|
||||
false // wasDragging = false (same position)
|
||||
)
|
||||
})
|
||||
|
||||
it('should distinguish drag from click based on distance threshold', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
// Test drag (distance > 4px)
|
||||
pointerHandlers.onPointerdown(
|
||||
createPointerEvent('pointerdown', { clientX: 100, clientY: 100 })
|
||||
)
|
||||
|
||||
const dragUpEvent = createPointerEvent('pointerup', {
|
||||
clientX: 200,
|
||||
clientY: 200
|
||||
})
|
||||
pointerHandlers.onPointerup(dragUpEvent)
|
||||
|
||||
expect(mockOnPointerUp).toHaveBeenCalledWith(
|
||||
dragUpEvent,
|
||||
mockNodeData,
|
||||
true
|
||||
)
|
||||
|
||||
mockOnPointerUp.mockClear()
|
||||
|
||||
// Test click (same position)
|
||||
const samePos = { clientX: 100, clientY: 100 }
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown', samePos))
|
||||
|
||||
const clickUpEvent = createPointerEvent('pointerup', samePos)
|
||||
pointerHandlers.onPointerup(clickUpEvent)
|
||||
|
||||
expect(mockOnPointerUp).toHaveBeenCalledWith(
|
||||
clickUpEvent,
|
||||
mockNodeData,
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle drag termination via cancel and context menu', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
// Test pointer cancel
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
pointerHandlers.onPointercancel(createPointerEvent('pointercancel'))
|
||||
|
||||
// Should not emit callback on cancel
|
||||
expect(mockOnPointerUp).not.toHaveBeenCalled()
|
||||
|
||||
// Test context menu during drag prevents default
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
|
||||
const contextMenuEvent = createMouseEvent('contextmenu')
|
||||
const preventDefaultSpy = vi.spyOn(contextMenuEvent, 'preventDefault')
|
||||
|
||||
pointerHandlers.onContextmenu(contextMenuEvent)
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not emit callback when nodeData becomes null', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
const nodeDataRef = ref<VueNodeData | null>(mockNodeData)
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
nodeDataRef,
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
|
||||
// Clear nodeData before pointerup
|
||||
nodeDataRef.value = null
|
||||
|
||||
pointerHandlers.onPointerup(createPointerEvent('pointerup'))
|
||||
|
||||
expect(mockOnPointerUp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should integrate with layout store dragging state', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
const { layoutStore } = await import(
|
||||
'@/renderer/core/layout/store/layoutStore'
|
||||
)
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
// Start drag
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
await nextTick()
|
||||
expect(layoutStore.isDraggingVueNodes.value).toBe(true)
|
||||
|
||||
// End drag
|
||||
pointerHandlers.onPointercancel(createPointerEvent('pointercancel'))
|
||||
await nextTick()
|
||||
expect(layoutStore.isDraggingVueNodes.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
import { type MaybeRefOrGetter, computed, onUnmounted, ref, toValue } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
||||
|
||||
// Treat tiny pointer jitter as a click, not a drag
|
||||
const DRAG_THRESHOLD_PX = 4
|
||||
|
||||
export function useNodePointerInteractions(
|
||||
nodeDataMaybe: MaybeRefOrGetter<VueNodeData | null>,
|
||||
onPointerUp: (
|
||||
event: PointerEvent,
|
||||
nodeData: VueNodeData,
|
||||
wasDragging: boolean
|
||||
) => void
|
||||
) {
|
||||
const nodeData = computed(() => {
|
||||
const value = toValue(nodeDataMaybe)
|
||||
if (!value) {
|
||||
console.warn(
|
||||
'useNodePointerInteractions: nodeDataMaybe resolved to null/undefined'
|
||||
)
|
||||
return null
|
||||
}
|
||||
return value
|
||||
})
|
||||
|
||||
// Avoid potential null access during component initialization
|
||||
const nodeIdComputed = computed(() => nodeData.value?.id ?? '')
|
||||
const { startDrag, endDrag, handleDrag } = useNodeLayout(nodeIdComputed)
|
||||
// Use canvas interactions for proper wheel event handling and pointer event capture control
|
||||
const { forwardEventToCanvas, shouldHandleNodePointerEvents } =
|
||||
useCanvasInteractions()
|
||||
|
||||
// Drag state for styling
|
||||
const isDragging = ref(false)
|
||||
const dragStyle = computed(() => {
|
||||
if (nodeData.value?.flags?.pinned) {
|
||||
return { cursor: 'default' }
|
||||
}
|
||||
return { cursor: isDragging.value ? 'grabbing' : 'grab' }
|
||||
})
|
||||
const startPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!nodeData.value) {
|
||||
console.warn(
|
||||
'LGraphNode: nodeData is null/undefined in handlePointerDown'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Only start drag on left-click (button 0)
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
|
||||
if (!shouldHandleNodePointerEvents.value) {
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Don't allow dragging if node is pinned (but still record position for selection)
|
||||
startPosition.value = { x: event.clientX, y: event.clientY }
|
||||
if (nodeData.value.flags?.pinned) {
|
||||
return
|
||||
}
|
||||
|
||||
// Start drag using layout system
|
||||
isDragging.value = true
|
||||
|
||||
// Set Vue node dragging state for selection toolbox
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
|
||||
startDrag(event)
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (isDragging.value) {
|
||||
void handleDrag(event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized cleanup function for drag state
|
||||
* Ensures consistent cleanup across all drag termination scenarios
|
||||
*/
|
||||
const cleanupDragState = () => {
|
||||
isDragging.value = false
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely ends drag operation with proper error handling
|
||||
* @param event - PointerEvent to end the drag with
|
||||
*/
|
||||
const safeDragEnd = async (event: PointerEvent): Promise<void> => {
|
||||
try {
|
||||
await endDrag(event)
|
||||
} catch (error) {
|
||||
console.error('Error during endDrag:', error)
|
||||
} finally {
|
||||
cleanupDragState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common drag termination handler with fallback cleanup
|
||||
*/
|
||||
const handleDragTermination = (event: PointerEvent, errorContext: string) => {
|
||||
safeDragEnd(event).catch((error) => {
|
||||
console.error(`Failed to complete ${errorContext}:`, error)
|
||||
cleanupDragState() // Fallback cleanup
|
||||
})
|
||||
}
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
if (isDragging.value) {
|
||||
handleDragTermination(event, 'drag end')
|
||||
}
|
||||
|
||||
// Don't emit node-click when canvas is in panning mode - forward to canvas instead
|
||||
if (!shouldHandleNodePointerEvents.value) {
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Emit node-click for selection handling in GraphCanvas
|
||||
const dx = event.clientX - startPosition.value.x
|
||||
const dy = event.clientY - startPosition.value.y
|
||||
const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX
|
||||
|
||||
if (!nodeData?.value) return
|
||||
onPointerUp(event, nodeData.value, wasDragging)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles pointer cancellation events (e.g., touch cancelled by browser)
|
||||
* Ensures drag state is properly cleaned up when pointer interaction is interrupted
|
||||
*/
|
||||
const handlePointerCancel = (event: PointerEvent) => {
|
||||
if (!isDragging.value) return
|
||||
handleDragTermination(event, 'drag cancellation')
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles right-click during drag operations
|
||||
* Cancels the current drag to prevent context menu from appearing while dragging
|
||||
*/
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
if (!isDragging.value) return
|
||||
|
||||
event.preventDefault()
|
||||
// Simply cleanup state without calling endDrag to avoid synthetic event creation
|
||||
cleanupDragState()
|
||||
}
|
||||
|
||||
// Cleanup on unmount to prevent resource leaks
|
||||
onUnmounted(() => {
|
||||
if (!isDragging.value) return
|
||||
cleanupDragState()
|
||||
})
|
||||
|
||||
const pointerHandlers = {
|
||||
onPointerdown: handlePointerDown,
|
||||
onPointermove: handlePointerMove,
|
||||
onPointerup: handlePointerUp,
|
||||
onPointercancel: handlePointerCancel,
|
||||
onContextmenu: handleContextMenu
|
||||
}
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
dragStyle,
|
||||
pointerHandlers
|
||||
}
|
||||
}
|
||||
121
src/renderer/extensions/vueNodes/composables/useNodeTooltips.ts
Normal file
121
src/renderer/extensions/vueNodes/composables/useNodeTooltips.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { TooltipDirectivePassThroughOptions } from 'primevue'
|
||||
import { type MaybeRef, type Ref, computed, 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'
|
||||
|
||||
/**
|
||||
* 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>
|
||||
) {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const settingsStore = useSettingStore()
|
||||
|
||||
// Check if tooltips are globally enabled
|
||||
const tooltipsEnabled = computed(() =>
|
||||
settingsStore.get('Comfy.EnableTooltips')
|
||||
)
|
||||
|
||||
// Get node definition for tooltip data
|
||||
const nodeDef = computed(() => nodeDefStore.nodeDefsByName[unref(nodeType)])
|
||||
|
||||
/**
|
||||
* Get tooltip text for node description (header hover)
|
||||
*/
|
||||
const getNodeDescription = computed(() => {
|
||||
if (!tooltipsEnabled.value || !nodeDef.value) return ''
|
||||
|
||||
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.description`
|
||||
return st(key, nodeDef.value.description || '')
|
||||
})
|
||||
|
||||
/**
|
||||
* Get tooltip text for input slots
|
||||
*/
|
||||
const getInputSlotTooltip = (slotName: string) => {
|
||||
if (!tooltipsEnabled.value || !nodeDef.value) return ''
|
||||
|
||||
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.inputs.${normalizeI18nKey(slotName)}.tooltip`
|
||||
const inputTooltip = nodeDef.value.inputs?.[slotName]?.tooltip ?? ''
|
||||
return st(key, inputTooltip)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tooltip text for output slots
|
||||
*/
|
||||
const getOutputSlotTooltip = (slotIndex: number) => {
|
||||
if (!tooltipsEnabled.value || !nodeDef.value) return ''
|
||||
|
||||
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.outputs.${slotIndex}.tooltip`
|
||||
const outputTooltip = nodeDef.value.outputs?.[slotIndex]?.tooltip ?? ''
|
||||
return st(key, outputTooltip)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tooltip text for widgets
|
||||
*/
|
||||
const getWidgetTooltip = (widget: SafeWidgetData) => {
|
||||
if (!tooltipsEnabled.value || !nodeDef.value) return ''
|
||||
|
||||
// First try widget-specific tooltip
|
||||
const widgetTooltip = (widget as { tooltip?: string }).tooltip
|
||||
if (widgetTooltip) return widgetTooltip
|
||||
|
||||
// Then try input-based tooltip lookup
|
||||
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.inputs.${normalizeI18nKey(widget.name)}.tooltip`
|
||||
const inputTooltip = nodeDef.value.inputs?.[widget.name]?.tooltip ?? ''
|
||||
return st(key, inputTooltip)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tooltip configuration object for v-tooltip directive
|
||||
*/
|
||||
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
|
||||
} = {
|
||||
value: tooltipText,
|
||||
showDelay: tooltipDelay as number,
|
||||
disabled: !tooltipsEnabled.value || !tooltipText,
|
||||
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'
|
||||
},
|
||||
arrow: {
|
||||
class: 'before:border-slate-300'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a container reference, append tooltips to it
|
||||
if (containerRef?.value) {
|
||||
config.appendTo = containerRef.value
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
return {
|
||||
tooltipsEnabled,
|
||||
getNodeDescription,
|
||||
getInputSlotTooltip,
|
||||
getOutputSlotTooltip,
|
||||
getWidgetTooltip,
|
||||
createTooltipConfig
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Node Z-Index Management Composable
|
||||
*
|
||||
* Provides focused functionality for managing node layering through z-index.
|
||||
* Integrates with the layout system to ensure proper visual ordering.
|
||||
*/
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
|
||||
interface NodeZIndexOptions {
|
||||
/**
|
||||
* Layout source for z-index mutations
|
||||
* @default LayoutSource.Vue
|
||||
*/
|
||||
layoutSource?: LayoutSource
|
||||
}
|
||||
|
||||
export function useNodeZIndex(options: NodeZIndexOptions = {}) {
|
||||
const { layoutSource = LayoutSource.Vue } = options
|
||||
const layoutMutations = useLayoutMutations()
|
||||
|
||||
/**
|
||||
* Bring node to front (highest z-index)
|
||||
* @param nodeId - The node to bring to front
|
||||
* @param source - Optional source override
|
||||
*/
|
||||
function bringNodeToFront(nodeId: NodeId, source?: LayoutSource) {
|
||||
layoutMutations.setSource(source ?? layoutSource)
|
||||
layoutMutations.bringNodeToFront(nodeId)
|
||||
}
|
||||
|
||||
return {
|
||||
bringNodeToFront
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Centralized Slot Element Tracking
|
||||
*
|
||||
* Registers slot connector DOM elements per node, measures their canvas-space
|
||||
* positions in a single batched pass, and caches offsets so that node moves
|
||||
* update slot positions without DOM reads.
|
||||
*/
|
||||
import { type Ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
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 {
|
||||
isPointEqual,
|
||||
isSizeEqual
|
||||
} from '@/renderer/core/layout/utils/geometry'
|
||||
import { useNodeSlotRegistryStore } from '@/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore'
|
||||
|
||||
// RAF batching
|
||||
const pendingNodes = new Set<string>()
|
||||
let rafId: number | null = null
|
||||
|
||||
function scheduleSlotLayoutSync(nodeId: string) {
|
||||
pendingNodes.add(nodeId)
|
||||
if (rafId == null) {
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
flushScheduledSlotLayoutSync()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function flushScheduledSlotLayoutSync() {
|
||||
if (pendingNodes.size === 0) return
|
||||
const conv = useSharedCanvasPositionConversion()
|
||||
for (const nodeId of Array.from(pendingNodes)) {
|
||||
pendingNodes.delete(nodeId)
|
||||
syncNodeSlotLayoutsFromDOM(nodeId, conv)
|
||||
}
|
||||
}
|
||||
|
||||
export function syncNodeSlotLayoutsFromDOM(
|
||||
nodeId: string,
|
||||
conv?: ReturnType<typeof useSharedCanvasPositionConversion>
|
||||
) {
|
||||
const nodeSlotRegistryStore = useNodeSlotRegistryStore()
|
||||
const node = nodeSlotRegistryStore.getNode(nodeId)
|
||||
if (!node) return
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (!nodeLayout) return
|
||||
|
||||
const batch: Array<{ key: string; layout: SlotLayout }> = []
|
||||
|
||||
for (const [slotKey, entry] of node.slots) {
|
||||
const rect = entry.el.getBoundingClientRect()
|
||||
const screenCenter: [number, number] = [
|
||||
rect.left + rect.width / 2,
|
||||
rect.top + rect.height / 2
|
||||
]
|
||||
const [x, y] = (
|
||||
conv ?? useSharedCanvasPositionConversion()
|
||||
).clientPosToCanvasPos(screenCenter)
|
||||
const centerCanvas = { x, y }
|
||||
|
||||
// Cache offset relative to node position for fast updates later
|
||||
entry.cachedOffset = {
|
||||
x: centerCanvas.x - nodeLayout.position.x,
|
||||
y: centerCanvas.y - nodeLayout.position.y
|
||||
}
|
||||
|
||||
// Persist layout in canvas coordinates
|
||||
const size = LiteGraph.NODE_SLOT_HEIGHT
|
||||
const half = size / 2
|
||||
batch.push({
|
||||
key: slotKey,
|
||||
layout: {
|
||||
nodeId,
|
||||
index: entry.index,
|
||||
type: entry.type,
|
||||
position: { x: centerCanvas.x, y: centerCanvas.y },
|
||||
bounds: {
|
||||
x: centerCanvas.x - half,
|
||||
y: centerCanvas.y - half,
|
||||
width: size,
|
||||
height: size
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if (batch.length) layoutStore.batchUpdateSlotLayouts(batch)
|
||||
}
|
||||
|
||||
function updateNodeSlotsFromCache(nodeId: string) {
|
||||
const nodeSlotRegistryStore = useNodeSlotRegistryStore()
|
||||
const node = nodeSlotRegistryStore.getNode(nodeId)
|
||||
if (!node) return
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (!nodeLayout) return
|
||||
|
||||
const batch: Array<{ key: string; layout: SlotLayout }> = []
|
||||
|
||||
for (const [slotKey, entry] of node.slots) {
|
||||
if (!entry.cachedOffset) {
|
||||
// schedule a sync to seed offset
|
||||
scheduleSlotLayoutSync(nodeId)
|
||||
continue
|
||||
}
|
||||
|
||||
const centerCanvas = {
|
||||
x: nodeLayout.position.x + entry.cachedOffset.x,
|
||||
y: nodeLayout.position.y + entry.cachedOffset.y
|
||||
}
|
||||
const size = LiteGraph.NODE_SLOT_HEIGHT
|
||||
const half = size / 2
|
||||
batch.push({
|
||||
key: slotKey,
|
||||
layout: {
|
||||
nodeId,
|
||||
index: entry.index,
|
||||
type: entry.type,
|
||||
position: { x: centerCanvas.x, y: centerCanvas.y },
|
||||
bounds: {
|
||||
x: centerCanvas.x - half,
|
||||
y: centerCanvas.y - half,
|
||||
width: size,
|
||||
height: size
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (batch.length) layoutStore.batchUpdateSlotLayouts(batch)
|
||||
}
|
||||
|
||||
export function useSlotElementTracking(options: {
|
||||
nodeId: string
|
||||
index: number
|
||||
type: 'input' | 'output'
|
||||
element: Ref<HTMLElement | null>
|
||||
}) {
|
||||
const { nodeId, index, type, element } = options
|
||||
const nodeSlotRegistryStore = useNodeSlotRegistryStore()
|
||||
|
||||
onMounted(() => {
|
||||
if (!nodeId) return
|
||||
const stop = watch(
|
||||
element,
|
||||
(el) => {
|
||||
if (!el) return
|
||||
|
||||
const node = nodeSlotRegistryStore.ensureNode(nodeId)
|
||||
|
||||
if (!node.stopWatch) {
|
||||
const layoutRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
|
||||
const stopPositionWatch = watch(
|
||||
() => layoutRef.value?.position,
|
||||
(newPosition, oldPosition) => {
|
||||
if (!newPosition) return
|
||||
if (!oldPosition || !isPointEqual(newPosition, oldPosition)) {
|
||||
updateNodeSlotsFromCache(nodeId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const stopSizeWatch = watch(
|
||||
() => layoutRef.value?.size,
|
||||
(newSize, oldSize) => {
|
||||
if (!newSize) return
|
||||
if (!oldSize || !isSizeEqual(newSize, oldSize)) {
|
||||
scheduleSlotLayoutSync(nodeId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
node.stopWatch = () => {
|
||||
stopPositionWatch()
|
||||
stopSizeWatch()
|
||||
}
|
||||
}
|
||||
|
||||
// Register slot
|
||||
const slotKey = getSlotKey(nodeId, index, type === 'input')
|
||||
|
||||
el.dataset.slotKey = slotKey
|
||||
node.slots.set(slotKey, { el, index, type })
|
||||
|
||||
// Seed initial sync from DOM
|
||||
scheduleSlotLayoutSync(nodeId)
|
||||
|
||||
// Stop watching once registered
|
||||
stop()
|
||||
},
|
||||
{ immediate: true, flush: 'post' }
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (!nodeId) return
|
||||
const node = nodeSlotRegistryStore.getNode(nodeId)
|
||||
if (!node) return
|
||||
|
||||
// Remove this slot from registry and layout
|
||||
const slotKey = getSlotKey(nodeId, index, type === 'input')
|
||||
const entry = node.slots.get(slotKey)
|
||||
if (entry) {
|
||||
delete entry.el.dataset.slotKey
|
||||
node.slots.delete(slotKey)
|
||||
}
|
||||
layoutStore.deleteSlotLayout(slotKey)
|
||||
|
||||
// If node has no more slots, clean up
|
||||
if (node.slots.size === 0) {
|
||||
if (node.stopWatch) node.stopWatch()
|
||||
nodeSlotRegistryStore.deleteNode(nodeId)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
requestSlotLayoutSync: () => scheduleSlotLayoutSync(nodeId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { type Fn, useEventListener } from '@vueuse/core'
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { evaluateCompatibility } from '@/renderer/core/canvas/links/slotLinkCompatibility'
|
||||
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 { app } from '@/scripts/app'
|
||||
|
||||
interface SlotInteractionOptions {
|
||||
nodeId: string
|
||||
index: number
|
||||
type: 'input' | 'output'
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
interface SlotInteractionHandlers {
|
||||
onPointerDown: (event: PointerEvent) => void
|
||||
}
|
||||
|
||||
interface PointerSession {
|
||||
begin: (pointerId: number) => void
|
||||
register: (...stops: Array<Fn | null | undefined>) => void
|
||||
matches: (event: PointerEvent) => boolean
|
||||
isActive: () => boolean
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
function createPointerSession(): PointerSession {
|
||||
let pointerId: number | null = null
|
||||
let stops: Fn[] = []
|
||||
|
||||
const begin = (id: number) => {
|
||||
pointerId = id
|
||||
}
|
||||
|
||||
const register = (...newStops: Array<Fn | null | undefined>) => {
|
||||
for (const stop of newStops) {
|
||||
if (typeof stop === 'function') {
|
||||
stops.push(stop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const matches = (event: PointerEvent) =>
|
||||
pointerId !== null && event.pointerId === pointerId
|
||||
|
||||
const isActive = () => pointerId !== null
|
||||
|
||||
const clear = () => {
|
||||
for (const stop of stops) {
|
||||
stop()
|
||||
}
|
||||
stops = []
|
||||
pointerId = null
|
||||
}
|
||||
|
||||
return { begin, register, matches, isActive, clear }
|
||||
}
|
||||
|
||||
export function useSlotLinkInteraction({
|
||||
nodeId,
|
||||
index,
|
||||
type,
|
||||
readonly
|
||||
}: SlotInteractionOptions): SlotInteractionHandlers {
|
||||
if (readonly) {
|
||||
return {
|
||||
onPointerDown: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
const { state, beginDrag, endDrag, updatePointerPosition } =
|
||||
useSlotLinkDragState()
|
||||
|
||||
function candidateFromTarget(
|
||||
target: EventTarget | null
|
||||
): SlotDropCandidate | null {
|
||||
if (!(target instanceof HTMLElement)) return null
|
||||
const key = target.dataset['slotKey']
|
||||
if (!key) return null
|
||||
|
||||
const layout = layoutStore.getSlotLayout(key)
|
||||
if (!layout) return null
|
||||
|
||||
const candidate: SlotDropCandidate = { layout, compatible: false }
|
||||
|
||||
if (state.source) {
|
||||
candidate.compatible = evaluateCompatibility(
|
||||
state.source,
|
||||
candidate
|
||||
).allowable
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
const conversion = useSharedCanvasPositionConversion()
|
||||
|
||||
const pointerSession = createPointerSession()
|
||||
|
||||
const cleanupInteraction = () => {
|
||||
pointerSession.clear()
|
||||
endDrag()
|
||||
}
|
||||
|
||||
const updatePointerState = (event: PointerEvent) => {
|
||||
const clientX = event.clientX
|
||||
const clientY = event.clientY
|
||||
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
|
||||
clientX,
|
||||
clientY
|
||||
])
|
||||
|
||||
updatePointerPosition(clientX, clientY, canvasX, canvasY)
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (!pointerSession.matches(event)) return
|
||||
updatePointerState(event)
|
||||
app.canvas?.setDirty(true)
|
||||
}
|
||||
|
||||
const connectSlots = (slotLayout: SlotLayout) => {
|
||||
const canvas = app.canvas
|
||||
const graph = canvas?.graph
|
||||
const source = state.source
|
||||
if (!canvas || !graph || !source) return
|
||||
|
||||
const sourceNode = graph.getNodeById(Number(source.nodeId))
|
||||
const targetNode = graph.getNodeById(Number(slotLayout.nodeId))
|
||||
if (!sourceNode || !targetNode) return
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
cleanupInteraction()
|
||||
app.canvas?.setDirty(true)
|
||||
}
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
finishInteraction(event)
|
||||
}
|
||||
|
||||
const handlePointerCancel = (event: PointerEvent) => {
|
||||
if (!pointerSession.matches(event)) return
|
||||
cleanupInteraction()
|
||||
app.canvas?.setDirty(true)
|
||||
}
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (event.button !== 0) return
|
||||
if (!nodeId) return
|
||||
if (pointerSession.isActive()) return
|
||||
|
||||
const canvas = app.canvas
|
||||
const graph = canvas?.graph
|
||||
if (!canvas || !graph) return
|
||||
|
||||
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 direction =
|
||||
slot?.dir ?? (type === 'input' ? LinkDirection.LEFT : LinkDirection.RIGHT)
|
||||
|
||||
beginDrag(
|
||||
{
|
||||
nodeId,
|
||||
slotIndex: index,
|
||||
type,
|
||||
direction,
|
||||
position: layout.position
|
||||
},
|
||||
event.pointerId
|
||||
)
|
||||
|
||||
pointerSession.begin(event.pointerId)
|
||||
|
||||
updatePointerState(event)
|
||||
|
||||
pointerSession.register(
|
||||
useEventListener(window, 'pointermove', handlePointerMove, {
|
||||
capture: true
|
||||
}),
|
||||
useEventListener(window, 'pointerup', handlePointerUp, {
|
||||
capture: true
|
||||
}),
|
||||
useEventListener(window, 'pointercancel', handlePointerCancel, {
|
||||
capture: true
|
||||
})
|
||||
)
|
||||
app.canvas?.setDirty(true)
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pointerSession.isActive()) {
|
||||
cleanupInteraction()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
onPointerDown
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Generic Vue Element Tracking System
|
||||
*
|
||||
* Automatically tracks DOM size and position changes for Vue-rendered elements
|
||||
* and syncs them to the layout store. Uses a single shared ResizeObserver for
|
||||
* performance, with elements identified by configurable data attributes.
|
||||
*
|
||||
* Supports different element types (nodes, slots, widgets, etc.) with
|
||||
* customizable data attributes and update handlers.
|
||||
*/
|
||||
import {
|
||||
type MaybeRefOrGetter,
|
||||
getCurrentInstance,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
toValue
|
||||
} from 'vue'
|
||||
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
|
||||
|
||||
import { syncNodeSlotLayoutsFromDOM } from './useSlotElementTracking'
|
||||
|
||||
/**
|
||||
* Generic update item for element bounds tracking
|
||||
*/
|
||||
interface ElementBoundsUpdate {
|
||||
/** Element identifier (could be nodeId, widgetId, slotId, etc.) */
|
||||
id: string
|
||||
/** Updated bounds */
|
||||
bounds: Bounds
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for different types of tracked elements
|
||||
*/
|
||||
interface ElementTrackingConfig {
|
||||
/** Data attribute name (e.g., 'nodeId') */
|
||||
dataAttribute: string
|
||||
/** Handler for processing bounds updates */
|
||||
updateHandler: (updates: ElementBoundsUpdate[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of tracking configurations by element type
|
||||
*/
|
||||
const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
|
||||
[
|
||||
'node',
|
||||
{
|
||||
dataAttribute: 'nodeId',
|
||||
updateHandler: (updates) => {
|
||||
const nodeUpdates = updates.map(({ id, bounds }) => ({
|
||||
nodeId: id as NodeId,
|
||||
bounds
|
||||
}))
|
||||
layoutStore.batchUpdateNodeBounds(nodeUpdates)
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
// Single ResizeObserver instance for all Vue elements
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
// Canvas is ready when this code runs; no defensive guards needed.
|
||||
const conv = useSharedCanvasPositionConversion()
|
||||
// Group updates by type, then flush via each config's handler
|
||||
const updatesByType = new Map<string, ElementBoundsUpdate[]>()
|
||||
// Track nodes whose slots should be resynced after node size changes
|
||||
const nodesNeedingSlotResync = new Set<string>()
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!(entry.target instanceof HTMLElement)) continue
|
||||
const element = entry.target
|
||||
|
||||
// Find which type this element belongs to
|
||||
let elementType: string | undefined
|
||||
let elementId: string | undefined
|
||||
|
||||
for (const [type, config] of trackingConfigs) {
|
||||
const id = element.dataset[config.dataAttribute]
|
||||
if (id) {
|
||||
elementType = type
|
||||
elementId = id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!elementType || !elementId) continue
|
||||
|
||||
// Use contentBoxSize when available; fall back to contentRect for older engines/tests
|
||||
const contentBox = Array.isArray(entry.contentBoxSize)
|
||||
? entry.contentBoxSize[0]
|
||||
: {
|
||||
inlineSize: entry.contentRect.width,
|
||||
blockSize: entry.contentRect.height
|
||||
}
|
||||
const width = contentBox.inlineSize
|
||||
const height = contentBox.blockSize
|
||||
|
||||
// Screen-space rect
|
||||
const rect = element.getBoundingClientRect()
|
||||
const [cx, cy] = conv.clientPosToCanvasPos([rect.left, rect.top])
|
||||
const topLeftCanvas = { x: cx, y: cy }
|
||||
const bounds: Bounds = {
|
||||
x: topLeftCanvas.x,
|
||||
y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT,
|
||||
width: Math.max(0, width),
|
||||
height: Math.max(0, height - LiteGraph.NODE_TITLE_HEIGHT)
|
||||
}
|
||||
|
||||
let updates = updatesByType.get(elementType)
|
||||
if (!updates) {
|
||||
updates = []
|
||||
updatesByType.set(elementType, updates)
|
||||
}
|
||||
updates.push({ id: elementId, bounds })
|
||||
|
||||
// If this entry is a node, mark it for slot layout resync
|
||||
if (elementType === 'node' && elementId) {
|
||||
nodesNeedingSlotResync.add(elementId)
|
||||
}
|
||||
}
|
||||
|
||||
// Flush per-type
|
||||
for (const [type, updates] of updatesByType) {
|
||||
const config = trackingConfigs.get(type)
|
||||
if (config && updates.length) config.updateHandler(updates)
|
||||
}
|
||||
|
||||
// After node bounds are updated, refresh slot cached offsets and layouts
|
||||
if (nodesNeedingSlotResync.size > 0) {
|
||||
for (const nodeId of nodesNeedingSlotResync) {
|
||||
syncNodeSlotLayoutsFromDOM(nodeId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Tracks DOM element size/position changes for a Vue component and syncs to layout store
|
||||
*
|
||||
* Sets up automatic ResizeObserver tracking when the component mounts and cleans up
|
||||
* when unmounted. The tracked element is identified by a data attribute set on the
|
||||
* component's root DOM element.
|
||||
*
|
||||
* @param appIdentifier - Application-level identifier for this tracked element (not a DOM ID)
|
||||
* Example: node ID like 'node-123', widget ID like 'widget-456'
|
||||
* @param trackingType - Type of element being tracked, determines which tracking config to use
|
||||
* Example: 'node' for Vue nodes, 'widget' for UI widgets
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Track a Vue node component with ID 'my-node-123'
|
||||
* useVueElementTracking('my-node-123', 'node')
|
||||
*
|
||||
* // Would set data-node-id="my-node-123" on the component's root element
|
||||
* // and sync size changes to layoutStore.batchUpdateNodeBounds()
|
||||
* ```
|
||||
*/
|
||||
export function useVueElementTracking(
|
||||
appIdentifierMaybe: MaybeRefOrGetter<string>,
|
||||
trackingType: string
|
||||
) {
|
||||
const appIdentifier = toValue(appIdentifierMaybe)
|
||||
onMounted(() => {
|
||||
const element = getCurrentInstance()?.proxy?.$el
|
||||
if (!(element instanceof HTMLElement) || !appIdentifier) return
|
||||
|
||||
const config = trackingConfigs.get(trackingType)
|
||||
if (!config) return
|
||||
|
||||
// Set the data attribute expected by the RO pipeline for this type
|
||||
element.dataset[config.dataAttribute] = appIdentifier
|
||||
resizeObserver.observe(element)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
const element = getCurrentInstance()?.proxy?.$el
|
||||
if (!(element instanceof HTMLElement)) return
|
||||
|
||||
const config = trackingConfigs.get(trackingType)
|
||||
if (!config) return
|
||||
|
||||
// Remove the data attribute and observer
|
||||
delete element.dataset[config.dataAttribute]
|
||||
resizeObserver.unobserve(element)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { type MaybeRefOrGetter, computed, toValue } from 'vue'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
/**
|
||||
* Composable for managing execution state of Vue-based nodes
|
||||
*
|
||||
* 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
|
||||
* @returns Object containing reactive execution state and progress
|
||||
*/
|
||||
export const useNodeExecutionState = (
|
||||
nodeIdMaybe: MaybeRefOrGetter<string>
|
||||
) => {
|
||||
const nodeId = toValue(nodeIdMaybe)
|
||||
const { uniqueExecutingNodeIdStrings, nodeProgressStates } =
|
||||
storeToRefs(useExecutionStore())
|
||||
|
||||
const executing = computed(() => {
|
||||
return uniqueExecutingNodeIdStrings.value.has(nodeId)
|
||||
})
|
||||
|
||||
const progress = computed(() => {
|
||||
const state = nodeProgressStates.value[nodeId]
|
||||
return 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
|
||||
})
|
||||
|
||||
const executionState = computed(() => {
|
||||
const state = progressState.value
|
||||
if (!state) return 'idle'
|
||||
return state.state
|
||||
})
|
||||
|
||||
return {
|
||||
executing,
|
||||
progress,
|
||||
progressPercentage,
|
||||
progressState,
|
||||
executionState
|
||||
}
|
||||
}
|
||||
210
src/renderer/extensions/vueNodes/layout/useNodeLayout.ts
Normal file
210
src/renderer/extensions/vueNodes/layout/useNodeLayout.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
type CSSProperties,
|
||||
type MaybeRefOrGetter,
|
||||
computed,
|
||||
inject,
|
||||
ref,
|
||||
toValue
|
||||
} from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource, type Point } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Composable for individual Vue node components
|
||||
* Uses customRef for shared write access with Canvas renderer
|
||||
*/
|
||||
export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
const nodeId = toValue(nodeIdMaybe)
|
||||
const mutations = useLayoutMutations()
|
||||
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||
|
||||
// Get transform utilities from TransformPane if available
|
||||
const transformState = inject(TransformStateKey)
|
||||
|
||||
// Get the customRef for this node (shared write access)
|
||||
const layoutRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
|
||||
// Computed properties for easy access
|
||||
const position = computed(() => {
|
||||
const layout = layoutRef.value
|
||||
const pos = layout?.position ?? { x: 0, y: 0 }
|
||||
return pos
|
||||
})
|
||||
const size = computed(
|
||||
() => layoutRef.value?.size ?? { width: 200, height: 100 }
|
||||
)
|
||||
const bounds = computed(
|
||||
() =>
|
||||
layoutRef.value?.bounds ?? {
|
||||
x: position.value.x,
|
||||
y: position.value.y,
|
||||
width: size.value.width,
|
||||
height: size.value.height
|
||||
}
|
||||
)
|
||||
const isVisible = computed(() => layoutRef.value?.visible ?? true)
|
||||
const zIndex = computed(() => layoutRef.value?.zIndex ?? 0)
|
||||
|
||||
// Drag state
|
||||
const isDragging = ref(false)
|
||||
let dragStartPos: Point | null = null
|
||||
let dragStartMouse: Point | null = null
|
||||
let otherSelectedNodesStartPositions: Map<string, Point> | null = null
|
||||
|
||||
/**
|
||||
* Start dragging the node
|
||||
*/
|
||||
function startDrag(event: PointerEvent) {
|
||||
if (!layoutRef.value || !transformState) return
|
||||
|
||||
isDragging.value = true
|
||||
dragStartPos = { ...position.value }
|
||||
dragStartMouse = { x: event.clientX, y: event.clientY }
|
||||
|
||||
// capture the starting positions of all other selected nodes
|
||||
if (selectedNodeIds?.value?.has(nodeId) && selectedNodeIds.value.size > 1) {
|
||||
otherSelectedNodesStartPositions = new Map()
|
||||
|
||||
// Iterate through all selected node IDs
|
||||
for (const id of selectedNodeIds.value) {
|
||||
// Skip the current node being dragged
|
||||
if (id === nodeId) continue
|
||||
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(id).value
|
||||
if (nodeLayout) {
|
||||
otherSelectedNodesStartPositions.set(id, { ...nodeLayout.position })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
otherSelectedNodesStartPositions = null
|
||||
}
|
||||
|
||||
// Set mutation source
|
||||
mutations.setSource(LayoutSource.Vue)
|
||||
|
||||
// Capture pointer
|
||||
if (!(event.target instanceof HTMLElement)) return
|
||||
event.target.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag movement
|
||||
*/
|
||||
const handleDrag = (event: PointerEvent) => {
|
||||
if (
|
||||
!isDragging.value ||
|
||||
!dragStartPos ||
|
||||
!dragStartMouse ||
|
||||
!transformState
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate mouse delta in screen coordinates
|
||||
const mouseDelta = {
|
||||
x: event.clientX - dragStartMouse.x,
|
||||
y: event.clientY - dragStartMouse.y
|
||||
}
|
||||
|
||||
// Convert to canvas coordinates
|
||||
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
|
||||
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
|
||||
const canvasDelta = {
|
||||
x: canvasWithDelta.x - canvasOrigin.x,
|
||||
y: canvasWithDelta.y - canvasOrigin.y
|
||||
}
|
||||
|
||||
// Calculate new position for the current node
|
||||
const newPosition = {
|
||||
x: dragStartPos.x + canvasDelta.x,
|
||||
y: dragStartPos.y + canvasDelta.y
|
||||
}
|
||||
|
||||
// Apply mutation through the layout system
|
||||
mutations.moveNode(nodeId, newPosition)
|
||||
|
||||
// If we're dragging multiple selected nodes, move them all together
|
||||
if (
|
||||
otherSelectedNodesStartPositions &&
|
||||
otherSelectedNodesStartPositions.size > 0
|
||||
) {
|
||||
for (const [otherNodeId, startPos] of otherSelectedNodesStartPositions) {
|
||||
const newOtherPosition = {
|
||||
x: startPos.x + canvasDelta.x,
|
||||
y: startPos.y + canvasDelta.y
|
||||
}
|
||||
mutations.moveNode(otherNodeId, newOtherPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End dragging
|
||||
*/
|
||||
function endDrag(event: PointerEvent) {
|
||||
if (!isDragging.value) return
|
||||
|
||||
isDragging.value = false
|
||||
dragStartPos = null
|
||||
dragStartMouse = null
|
||||
otherSelectedNodesStartPositions = null
|
||||
|
||||
// Release pointer
|
||||
if (!(event.target instanceof HTMLElement)) return
|
||||
event.target.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node position directly (without drag)
|
||||
*/
|
||||
function moveTo(position: Point) {
|
||||
mutations.setSource(LayoutSource.Vue)
|
||||
mutations.moveNode(nodeId, position)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node size
|
||||
*/
|
||||
function resize(newSize: { width: number; height: number }) {
|
||||
mutations.setSource(LayoutSource.Vue)
|
||||
mutations.resizeNode(nodeId, newSize)
|
||||
}
|
||||
|
||||
return {
|
||||
// Reactive state (via customRef)
|
||||
layoutRef,
|
||||
position,
|
||||
size,
|
||||
bounds,
|
||||
isVisible,
|
||||
zIndex,
|
||||
isDragging,
|
||||
|
||||
// Mutations
|
||||
moveTo,
|
||||
resize,
|
||||
|
||||
// Drag handlers
|
||||
startDrag,
|
||||
handleDrag,
|
||||
endDrag,
|
||||
|
||||
// Computed styles for Vue templates
|
||||
nodeStyle: computed(
|
||||
(): CSSProperties => ({
|
||||
position: 'absolute' as const,
|
||||
left: `${position.value.x}px`,
|
||||
top: `${position.value.y}px`,
|
||||
width: `${size.value.width}px`,
|
||||
height: `${size.value.height}px`,
|
||||
zIndex: zIndex.value,
|
||||
cursor: isDragging.value ? 'grabbing' : 'grab'
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
34
src/renderer/extensions/vueNodes/lod/useLOD.ts
Normal file
34
src/renderer/extensions/vueNodes/lod/useLOD.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Level of Detail (LOD) composable for Vue-based node rendering
|
||||
*
|
||||
* Provides dynamic quality adjustment based on zoom level to maintain
|
||||
* performance with large node graphs. Uses zoom threshold based on DPR
|
||||
* to determine how much detail to render for each node component.
|
||||
* Default minFontSize = 8px
|
||||
* Default zoomThreshold = 0.57 (On a DPR = 1 monitor)
|
||||
**/
|
||||
import { useDevicePixelRatio } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
interface Camera {
|
||||
z: number // zoom level
|
||||
}
|
||||
|
||||
export function useLOD(camera: Camera) {
|
||||
const isLOD = computed(() => {
|
||||
const { pixelRatio } = useDevicePixelRatio()
|
||||
const baseFontSize = 14
|
||||
const dprAdjustment = Math.sqrt(pixelRatio.value)
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const minFontSize = settingStore.get('LiteGraph.Canvas.MinFontSizeForLOD') //default 8
|
||||
const threshold =
|
||||
Math.round((minFontSize / (baseFontSize * dprAdjustment)) * 100) / 100 //round to 2 decimal places i.e 0.86
|
||||
|
||||
return camera.z < threshold
|
||||
})
|
||||
|
||||
return { isLOD }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { type MaybeRefOrGetter, type Ref, computed, toValue } from 'vue'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
export const useNodePreviewState = (
|
||||
nodeIdMaybe: MaybeRefOrGetter<string>,
|
||||
options?: {
|
||||
isCollapsed?: Ref<boolean>
|
||||
}
|
||||
) => {
|
||||
const nodeId = toValue(nodeIdMaybe)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { nodePreviewImages } = storeToRefs(useNodeOutputStore())
|
||||
|
||||
const locatorId = computed(() => workflowStore.nodeIdToNodeLocatorId(nodeId))
|
||||
|
||||
const previewUrls = computed(() => {
|
||||
const key = locatorId.value
|
||||
if (!key) return undefined
|
||||
const urls = nodePreviewImages.value[key]
|
||||
return urls?.length ? urls : undefined
|
||||
})
|
||||
|
||||
const hasPreview = computed(() => !!previewUrls.value?.length)
|
||||
|
||||
const latestPreviewUrl = computed(() => {
|
||||
const urls = previewUrls.value
|
||||
return urls?.length ? urls.at(-1) : ''
|
||||
})
|
||||
|
||||
const shouldShowPreviewImg = computed(() => {
|
||||
if (!options?.isCollapsed) {
|
||||
return hasPreview.value
|
||||
}
|
||||
return !options.isCollapsed.value && hasPreview.value
|
||||
})
|
||||
|
||||
return {
|
||||
locatorId,
|
||||
previewUrls,
|
||||
hasPreview,
|
||||
latestPreviewUrl,
|
||||
shouldShowPreviewImg
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
type SlotEntry = {
|
||||
el: HTMLElement
|
||||
index: number
|
||||
type: 'input' | 'output'
|
||||
cachedOffset?: { x: number; y: number }
|
||||
}
|
||||
|
||||
type NodeEntry = {
|
||||
nodeId: string
|
||||
slots: Map<string, SlotEntry>
|
||||
stopWatch?: () => void
|
||||
}
|
||||
|
||||
export const useNodeSlotRegistryStore = defineStore('nodeSlotRegistry', () => {
|
||||
const registry = markRaw(new Map<string, NodeEntry>())
|
||||
|
||||
function getNode(nodeId: string) {
|
||||
return registry.get(nodeId)
|
||||
}
|
||||
|
||||
function ensureNode(nodeId: string) {
|
||||
let node = registry.get(nodeId)
|
||||
if (!node) {
|
||||
node = {
|
||||
nodeId,
|
||||
slots: markRaw(new Map<string, SlotEntry>())
|
||||
}
|
||||
registry.set(nodeId, node)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function deleteNode(nodeId: string) {
|
||||
registry.delete(nodeId)
|
||||
}
|
||||
|
||||
function clear() {
|
||||
registry.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
getNode,
|
||||
ensureNode,
|
||||
deleteNode,
|
||||
clear
|
||||
}
|
||||
})
|
||||
14
src/renderer/extensions/vueNodes/utils/nodeStyleUtils.ts
Normal file
14
src/renderer/extensions/vueNodes/utils/nodeStyleUtils.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
|
||||
/**
|
||||
* Applies light theme color adjustments to a color
|
||||
*/
|
||||
export function applyLightThemeColor(
|
||||
color: string,
|
||||
isLightTheme: boolean
|
||||
): string {
|
||||
if (!color || !isLightTheme) {
|
||||
return color
|
||||
}
|
||||
return adjustColor(color, { lightness: 0.5 })
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Button from 'primevue/button'
|
||||
import type { ButtonProps } from 'primevue/button'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import WidgetButton from '@/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
describe('WidgetButton Interactions', () => {
|
||||
const createMockWidget = (
|
||||
options: Partial<ButtonProps> = {},
|
||||
callback?: () => void,
|
||||
name: string = 'test_button'
|
||||
): SimplifiedWidget<void> => ({
|
||||
name,
|
||||
type: 'button',
|
||||
value: undefined,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
|
||||
const mountComponent = (widget: SimplifiedWidget<void>, readonly = false) => {
|
||||
return mount(WidgetButton, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { Button }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
readonly
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const clickButton = async (wrapper: ReturnType<typeof mount>) => {
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
await button.trigger('click')
|
||||
return button
|
||||
}
|
||||
|
||||
describe('Click Handling', () => {
|
||||
it('calls callback when button is clicked', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const widget = createMockWidget({}, mockCallback)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
await clickButton(wrapper)
|
||||
|
||||
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)
|
||||
|
||||
// Should not throw error when clicking without callback
|
||||
await expect(clickButton(wrapper)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('calls callback multiple times when clicked multiple times', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const widget = createMockWidget({}, mockCallback)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const numClicks = 8
|
||||
|
||||
await clickButton(wrapper)
|
||||
for (let i = 0; i < numClicks; i++) {
|
||||
await clickButton(wrapper)
|
||||
}
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(numClicks)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('renders button component', () => {
|
||||
const widget = createMockWidget()
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders widget label when name is provided', () => {
|
||||
const widget = createMockWidget()
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const label = wrapper.find('label')
|
||||
expect(label.exists()).toBe(true)
|
||||
expect(label.text()).toBe('test_button')
|
||||
})
|
||||
|
||||
it('does not render label when widget name is empty', () => {
|
||||
const widget = createMockWidget({}, undefined, '')
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const label = wrapper.find('label')
|
||||
expect(label.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('sets button size to small', () => {
|
||||
const widget = createMockWidget()
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.props('size')).toBe('small')
|
||||
})
|
||||
|
||||
it('passes widget options to button component', () => {
|
||||
const buttonOptions = {
|
||||
label: 'Custom Label',
|
||||
icon: 'pi pi-check',
|
||||
severity: 'success' as const
|
||||
}
|
||||
const widget = createMockWidget(buttonOptions)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.props('label')).toBe('Custom Label')
|
||||
expect(button.props('icon')).toBe('pi pi-check')
|
||||
expect(button.props('severity')).toBe('success')
|
||||
})
|
||||
})
|
||||
|
||||
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' })
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.props('label')).toBe('Click Me')
|
||||
expect(button.props('icon')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles button with icon only', () => {
|
||||
const widget = createMockWidget({ icon: 'pi pi-star' })
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.props('icon')).toBe('pi pi-star')
|
||||
})
|
||||
|
||||
it('handles button with both text and icon', () => {
|
||||
const widget = createMockWidget({
|
||||
label: 'Save',
|
||||
icon: 'pi pi-save'
|
||||
})
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.props('label')).toBe('Save')
|
||||
expect(button.props('icon')).toBe('pi pi-save')
|
||||
})
|
||||
|
||||
it.for([
|
||||
'secondary',
|
||||
'success',
|
||||
'info',
|
||||
'warning',
|
||||
'danger',
|
||||
'help',
|
||||
'contrast'
|
||||
] as const)('handles button severity: %s', (severity) => {
|
||||
const widget = createMockWidget({ severity })
|
||||
const wrapper = mountComponent(widget)
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.props('severity')).toBe(severity)
|
||||
})
|
||||
|
||||
it.for(['outlined', 'text'] as const)(
|
||||
'handles button variant: %s',
|
||||
(variant) => {
|
||||
const widget = createMockWidget({ variant })
|
||||
const wrapper = mountComponent(widget)
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.props('variant')).toBe(variant)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles widget with no options', () => {
|
||||
const widget = createMockWidget()
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles callback that throws error', async () => {
|
||||
const mockCallback = vi.fn(() => {
|
||||
throw new Error('Callback error')
|
||||
})
|
||||
const widget = createMockWidget({}, mockCallback)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
// Should not break the component when callback throws
|
||||
await expect(clickButton(wrapper)).rejects.toThrow('Callback error')
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('handles rapid consecutive clicks', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const widget = createMockWidget({}, mockCallback)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
// Simulate rapid clicks
|
||||
const clickPromises = Array.from({ length: 16 }, () =>
|
||||
clickButton(wrapper)
|
||||
)
|
||||
await Promise.all(clickPromises)
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(16)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<Button
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
size="small"
|
||||
@click="handleClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
BADGE_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
// Button widgets don't have a v-model value, they trigger actions
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<void>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
// Button specific excluded props
|
||||
const BUTTON_EXCLUDED_PROPS = [...BADGE_EXCLUDED_PROPS, 'iconClass'] as const
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, BUTTON_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
const handleClick = () => {
|
||||
if (!props.readonly && props.widget.callback) {
|
||||
props.widget.callback()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class="p-4 border border-gray-300 dark-theme:border-gray-600 rounded max-h-[48rem]"
|
||||
>
|
||||
<Chart :type="chartType" :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChartData } from 'chart.js'
|
||||
import Chart from 'primevue/chart'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ChartInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
type ChartWidgetOptions = NonNullable<ChartInputSpec['options']>
|
||||
|
||||
const value = defineModel<ChartData>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<ChartData, ChartWidgetOptions>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const chartType = computed(() => props.widget.options?.type ?? 'line')
|
||||
|
||||
const chartData = computed(() => value.value || { labels: [], datasets: [] })
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#FFF',
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle'
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#9FA2BD'
|
||||
},
|
||||
grid: {
|
||||
display: true,
|
||||
color: '#9FA2BD',
|
||||
drawTicks: false,
|
||||
drawOnChartArea: true,
|
||||
drawBorder: false
|
||||
},
|
||||
border: {
|
||||
display: true,
|
||||
color: '#9FA2BD'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: '#9FA2BD'
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
drawTicks: false,
|
||||
drawOnChartArea: false,
|
||||
drawBorder: false
|
||||
},
|
||||
border: {
|
||||
display: true,
|
||||
color: '#9FA2BD'
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
@@ -0,0 +1,304 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import type { ColorPickerProps } from 'primevue/colorpicker'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetColorPicker from './WidgetColorPicker.vue'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
describe('WidgetColorPicker Value Binding', () => {
|
||||
const createMockWidget = (
|
||||
value: string = '#000000',
|
||||
options: Partial<ColorPickerProps> = {},
|
||||
callback?: (value: string) => void
|
||||
): SimplifiedWidget<string> => ({
|
||||
name: 'test_color_picker',
|
||||
type: 'color',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string>,
|
||||
modelValue: string,
|
||||
readonly = false
|
||||
) => {
|
||||
return mount(WidgetColorPicker, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: {
|
||||
ColorPicker,
|
||||
WidgetLayoutField
|
||||
}
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setColorPickerValue = async (
|
||||
wrapper: ReturnType<typeof mount>,
|
||||
value: unknown
|
||||
) => {
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
await colorPicker.setValue(value)
|
||||
return wrapper.emitted('update:modelValue')
|
||||
}
|
||||
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when color changes', async () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '#00ff00')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#00ff00')
|
||||
})
|
||||
|
||||
it('handles different color formats', async () => {
|
||||
const widget = createMockWidget('#ffffff')
|
||||
const wrapper = mountComponent(widget, '#ffffff')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '#123abc')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#123abc')
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget('#000000', {}, undefined)
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '#ff00ff')
|
||||
|
||||
// Should still emit Vue event
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#ff00ff')
|
||||
})
|
||||
|
||||
it('normalizes bare hex without # to #hex on emit', async () => {
|
||||
const widget = createMockWidget('ff0000')
|
||||
const wrapper = mountComponent(widget, 'ff0000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '00ff00')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#00ff00')
|
||||
})
|
||||
|
||||
it('normalizes rgb() strings to #hex on emit', async () => {
|
||||
const widget = createMockWidget('#000000')
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, 'rgb(255, 0, 0)')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#ff0000')
|
||||
})
|
||||
|
||||
it('normalizes hsb() strings to #hex on emit', async () => {
|
||||
const widget = createMockWidget('#000000', { format: 'hsb' })
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, 'hsb(120, 100, 100)')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#00ff00')
|
||||
})
|
||||
|
||||
it('normalizes HSB object values to #hex on emit', async () => {
|
||||
const widget = createMockWidget('#000000', { format: 'hsb' })
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, {
|
||||
h: 240,
|
||||
s: 100,
|
||||
b: 100
|
||||
})
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#0000ff')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('renders color picker component', () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes display to a single leading #', () => {
|
||||
// Case 1: model value already includes '#'
|
||||
let widget = createMockWidget('#ff0000')
|
||||
let wrapper = mountComponent(widget, '#ff0000')
|
||||
let colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect.soft(colorText.text()).toBe('#ff0000')
|
||||
|
||||
// Case 2: model value missing '#'
|
||||
widget = createMockWidget('ff0000')
|
||||
wrapper = mountComponent(widget, 'ff0000')
|
||||
colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect.soft(colorText.text()).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('renders layout field wrapper', () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
expect(layoutField.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays current color value as text', () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect(colorText.text()).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('updates color text when value changes', async () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
await setColorPickerValue(wrapper, '#00ff00')
|
||||
|
||||
// Need to check the local state update
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
// Be specific about the displayed value including the leading '#'
|
||||
expect.soft(colorText.text()).toBe('#00ff00')
|
||||
})
|
||||
|
||||
it('uses default color when no value provided', () => {
|
||||
const widget = createMockWidget('')
|
||||
const wrapper = mountComponent(widget, '')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
// Should use the default value from the composable
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
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 = [
|
||||
'#000000',
|
||||
'#ffffff',
|
||||
'#ff0000',
|
||||
'#00ff00',
|
||||
'#0000ff',
|
||||
'#123abc'
|
||||
]
|
||||
|
||||
for (const color of validHexColors) {
|
||||
const widget = createMockWidget(color)
|
||||
const wrapper = mountComponent(widget, color)
|
||||
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect.soft(colorText.text()).toBe(color)
|
||||
}
|
||||
})
|
||||
|
||||
it('handles short hex colors', () => {
|
||||
const widget = createMockWidget('#fff')
|
||||
const wrapper = mountComponent(widget, '#fff')
|
||||
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect(colorText.text()).toBe('#fff')
|
||||
})
|
||||
|
||||
it('passes widget options to color picker', () => {
|
||||
const colorOptions = {
|
||||
format: 'hex' as const,
|
||||
inline: true
|
||||
}
|
||||
const widget = createMockWidget('#ff0000', colorOptions)
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
expect(colorPicker.props('format')).toBe('hex')
|
||||
expect(colorPicker.props('inline')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Widget Layout Integration', () => {
|
||||
it('passes widget to layout field', () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
expect(layoutField.props('widget')).toEqual(widget)
|
||||
})
|
||||
|
||||
it('maintains proper component structure', () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
// Should have layout field containing label with color picker and text
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
const label = wrapper.find('label')
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
const colorText = wrapper.find('span')
|
||||
|
||||
expect(layoutField.exists()).toBe(true)
|
||||
expect(label.exists()).toBe(true)
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
expect(colorText.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles empty color value', () => {
|
||||
const widget = createMockWidget('')
|
||||
const wrapper = mountComponent(widget, '')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles invalid color formats gracefully', async () => {
|
||||
const widget = createMockWidget('invalid-color')
|
||||
const wrapper = mountComponent(widget, 'invalid-color')
|
||||
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect(colorText.text()).toBe('#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, 'invalid-color')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#000000')
|
||||
})
|
||||
|
||||
it('handles widget with no options', () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
<!-- Needs custom color picker for alpha support -->
|
||||
<template>
|
||||
<WidgetLayoutField :widget="widget">
|
||||
<label
|
||||
:class="
|
||||
cn(WidgetInputBaseClass, 'flex items-center gap-2 w-full px-4 py-2')
|
||||
"
|
||||
>
|
||||
<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'
|
||||
}"
|
||||
@update:model-value="onPickerUpdate"
|
||||
/>
|
||||
<span class="text-xs" data-testid="widget-color-text">{{
|
||||
toHexFromFormat(localValue, format)
|
||||
}}</span>
|
||||
</label>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
type ColorFormat,
|
||||
type HSB,
|
||||
isColorFormat,
|
||||
toHexFromFormat
|
||||
} from '@/utils/colorUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
type WidgetOptions = { format?: ColorFormat } & Record<string, unknown>
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string, WidgetOptions>
|
||||
modelValue: string
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const format = computed<ColorFormat>(() => {
|
||||
const optionFormat = props.widget.options?.format
|
||||
return isColorFormat(optionFormat) ? optionFormat : 'hex'
|
||||
})
|
||||
|
||||
type PickerValue = string | HSB
|
||||
const localValue = ref<PickerValue>(
|
||||
toHexFromFormat(
|
||||
props.modelValue || '#000000',
|
||||
isColorFormat(props.widget.options?.format)
|
||||
? props.widget.options.format
|
||||
: 'hex'
|
||||
)
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
localValue.value = toHexFromFormat(newVal || '#000000', format.value)
|
||||
}
|
||||
)
|
||||
|
||||
function onPickerUpdate(val: unknown) {
|
||||
localValue.value = val as PickerValue
|
||||
emit('update:modelValue', toHexFromFormat(val, format.value))
|
||||
}
|
||||
|
||||
// ColorPicker specific excluded props include panel/overlay classes
|
||||
const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, COLOR_PICKER_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,588 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Button from 'primevue/button'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Select from 'primevue/select'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import { createMockFile, createMockWidget } from '../testUtils'
|
||||
import WidgetFileUpload from './WidgetFileUpload.vue'
|
||||
|
||||
describe('WidgetFileUpload File Handling', () => {
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<File[] | null>,
|
||||
modelValue: File[] | null,
|
||||
readonly = false
|
||||
) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
...enMessages,
|
||||
'Drop your file or': 'Drop your file or'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return mount(WidgetFileUpload, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
components: { Button, Select }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const mockObjectURL = 'blob:mock-url'
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock URL.createObjectURL and revokeObjectURL
|
||||
global.URL.createObjectURL = vi.fn(() => mockObjectURL)
|
||||
global.URL.revokeObjectURL = vi.fn()
|
||||
})
|
||||
|
||||
describe('Initial States', () => {
|
||||
it('shows upload UI when no file is selected', () => {
|
||||
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
})
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
expect(wrapper.text()).toContain('Drop your file or')
|
||||
expect(wrapper.text()).toContain('Browse Files')
|
||||
expect(wrapper.find('button').text()).toBe('Browse Files')
|
||||
})
|
||||
|
||||
it('renders file input with correct attributes', () => {
|
||||
const widget = createMockWidget<File[] | null>(
|
||||
null,
|
||||
{ accept: 'image/*' },
|
||||
undefined,
|
||||
{
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
}
|
||||
)
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const fileInput = wrapper.find('input[type="file"]')
|
||||
expect(fileInput.exists()).toBe(true)
|
||||
expect(fileInput.attributes('accept')).toBe('image/*')
|
||||
expect(fileInput.classes()).toContain('hidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Selection', () => {
|
||||
it('triggers file input when browse button is clicked', async () => {
|
||||
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
})
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const fileInput = wrapper.find('input[type="file"]')
|
||||
const inputElement = fileInput.element
|
||||
if (!(inputElement instanceof HTMLInputElement)) {
|
||||
throw new Error('Expected HTMLInputElement')
|
||||
}
|
||||
const clickSpy = vi.spyOn(inputElement, 'click')
|
||||
|
||||
const browseButton = wrapper.find('button')
|
||||
await browseButton.trigger('click')
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles file selection', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const widget = createMockWidget<File[] | null>(null, {}, mockCallback, {
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
})
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
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).toBeDefined()
|
||||
expect(emitted![0]).toEqual([[file]])
|
||||
})
|
||||
|
||||
it('resets file input after selection', async () => {
|
||||
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
})
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
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 inputElement = fileInput.element
|
||||
if (!(inputElement instanceof HTMLInputElement)) {
|
||||
throw new Error('Expected HTMLInputElement')
|
||||
}
|
||||
expect(inputElement.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Image File Display', () => {
|
||||
it('shows image preview for image files', () => {
|
||||
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])
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe(mockObjectURL)
|
||||
expect(img.attributes('alt')).toBe('test.jpg')
|
||||
})
|
||||
|
||||
it('shows select dropdown with filename for images', () => {
|
||||
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])
|
||||
|
||||
const select = wrapper.getComponent({ name: 'Select' })
|
||||
expect(select.props('modelValue')).toBe('test.jpg')
|
||||
expect(select.props('options')).toEqual(['test.jpg'])
|
||||
expect(select.props('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows edit and delete buttons on hover for images', () => {
|
||||
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])
|
||||
|
||||
// The pi-pencil and pi-times classes are on the <i> elements inside the buttons
|
||||
const editIcon = wrapper.find('i.pi-pencil')
|
||||
const deleteIcon = wrapper.find('i.pi-times')
|
||||
|
||||
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', () => {
|
||||
it('shows audio player for audio files', () => {
|
||||
const audioFile = createMockFile('test.mp3', 'audio/mpeg')
|
||||
const widget = createMockWidget<File[] | null>(
|
||||
[audioFile],
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
}
|
||||
)
|
||||
const wrapper = mountComponent(widget, [audioFile])
|
||||
|
||||
expect(wrapper.find('.pi-volume-up').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('test.mp3')
|
||||
expect(wrapper.text()).toContain('1.0 KB')
|
||||
})
|
||||
|
||||
it('shows file size for audio files', () => {
|
||||
const audioFile = createMockFile('test.mp3', 'audio/mpeg', 2048)
|
||||
const widget = createMockWidget<File[] | null>(
|
||||
[audioFile],
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
}
|
||||
)
|
||||
const wrapper = mountComponent(widget, [audioFile])
|
||||
|
||||
expect(wrapper.text()).toContain('2.0 KB')
|
||||
})
|
||||
|
||||
it('shows delete button for audio files', () => {
|
||||
const audioFile = createMockFile('test.mp3', 'audio/mpeg')
|
||||
const widget = createMockWidget<File[] | null>(
|
||||
[audioFile],
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
}
|
||||
)
|
||||
const wrapper = mountComponent(widget, [audioFile])
|
||||
|
||||
const deleteIcon = wrapper.find('i.pi-times')
|
||||
expect(deleteIcon.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Type Detection', () => {
|
||||
const imageFiles = [
|
||||
{ name: 'image.jpg', type: 'image/jpeg' },
|
||||
{ name: 'image.png', type: 'image/png' }
|
||||
]
|
||||
|
||||
const audioFiles = [
|
||||
{ name: 'audio.mp3', type: 'audio/mpeg' },
|
||||
{ name: 'audio.wav', type: 'audio/wav' }
|
||||
]
|
||||
|
||||
const normalFiles = [
|
||||
{ name: 'video.mp4', type: 'video/mp4' },
|
||||
{ name: 'document.pdf', type: 'application/pdf' }
|
||||
]
|
||||
|
||||
it.for(imageFiles)(
|
||||
'shows image preview for $type files',
|
||||
({ name, type }) => {
|
||||
const file = createMockFile(name, type)
|
||||
const widget = createMockWidget<File[] | null>([file], {}, undefined, {
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
})
|
||||
const wrapper = mountComponent(widget, [file])
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
expect(wrapper.find('.pi-volume-up').exists()).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it.for(audioFiles)(
|
||||
'shows audio player for $type files',
|
||||
({ name, type }) => {
|
||||
const file = createMockFile(name, type)
|
||||
const widget = createMockWidget<File[] | null>([file], {}, undefined, {
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
})
|
||||
const wrapper = mountComponent(widget, [file])
|
||||
|
||||
expect(wrapper.find('.pi-volume-up').exists()).toBe(true)
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it.for(normalFiles)('shows normal UI for $type files', ({ name, type }) => {
|
||||
const file = createMockFile(name, type)
|
||||
const widget = createMockWidget<File[] | null>([file], {}, undefined, {
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
})
|
||||
const wrapper = mountComponent(widget, [file])
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.find('.pi-volume-up').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Actions', () => {
|
||||
it('clears file when delete button is clicked', async () => {
|
||||
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])
|
||||
|
||||
// Find button that contains the times icon
|
||||
const buttons = wrapper.findAll('button')
|
||||
const deleteButton = buttons.find((button) =>
|
||||
button.find('i.pi-times').exists()
|
||||
)
|
||||
|
||||
if (!deleteButton) {
|
||||
throw new Error('Delete button with times icon not found')
|
||||
}
|
||||
|
||||
await deleteButton.trigger('click')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![emitted!.length - 1]).toEqual([null])
|
||||
})
|
||||
|
||||
it('handles edit button click', async () => {
|
||||
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])
|
||||
|
||||
// Find button that contains the pencil icon
|
||||
const buttons = wrapper.findAll('button')
|
||||
const editButton = buttons.find((button) =>
|
||||
button.find('i.pi-pencil').exists()
|
||||
)
|
||||
|
||||
if (!editButton) {
|
||||
throw new Error('Edit button with pencil icon not found')
|
||||
}
|
||||
|
||||
// Should not throw error when clicked (TODO: implement edit functionality)
|
||||
await expect(editButton.trigger('click')).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('triggers file input when folder button is clicked', async () => {
|
||||
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])
|
||||
|
||||
const fileInput = wrapper.find('input[type="file"]')
|
||||
const inputElement = fileInput.element
|
||||
if (!(inputElement instanceof HTMLInputElement)) {
|
||||
throw new Error('Expected HTMLInputElement')
|
||||
}
|
||||
const clickSpy = vi.spyOn(inputElement, 'click')
|
||||
|
||||
// Find PrimeVue Button component with folder icon
|
||||
const folderButton = wrapper.getComponent(Button)
|
||||
|
||||
await folderButton.trigger('click')
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
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, {
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
})
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const fileInput = wrapper.find('input[type="file"]')
|
||||
|
||||
Object.defineProperty(fileInput.element, 'files', {
|
||||
value: [],
|
||||
writable: false
|
||||
})
|
||||
|
||||
await fileInput.trigger('change')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles missing file input gracefully', () => {
|
||||
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
})
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
// Remove file input ref to simulate missing element
|
||||
wrapper.vm.$refs.fileInputRef = null
|
||||
|
||||
// Should not throw error when method exists
|
||||
const vm = wrapper.vm as any
|
||||
expect(() => vm.triggerFileInput?.()).not.toThrow()
|
||||
})
|
||||
|
||||
it('handles clearing file when no file input exists', async () => {
|
||||
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])
|
||||
|
||||
// Remove file input ref to simulate missing element
|
||||
wrapper.vm.$refs.fileInputRef = null
|
||||
|
||||
// Find button that contains the times icon
|
||||
const buttons = wrapper.findAll('button')
|
||||
const deleteButton = buttons.find((button) =>
|
||||
button.find('i.pi-times').exists()
|
||||
)
|
||||
|
||||
if (!deleteButton) {
|
||||
throw new Error('Delete button with times icon not found')
|
||||
}
|
||||
|
||||
// Should not throw error
|
||||
await expect(deleteButton.trigger('click')).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('cleans up object URLs on unmount', () => {
|
||||
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])
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(global.URL.revokeObjectURL).toHaveBeenCalledWith(mockObjectURL)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<!-- Replace entire widget with image preview when image is loaded -->
|
||||
<!-- Edge-to-edge: -mx-2 removes the parent's p-2 (8px) padding on each side -->
|
||||
<div
|
||||
v-if="hasImageFile"
|
||||
class="relative -mx-2"
|
||||
style="width: calc(100% + 1rem)"
|
||||
>
|
||||
<!-- Select section above image -->
|
||||
<div class="flex items-center justify-between gap-4 mb-2 px-2">
|
||||
<label
|
||||
v-if="widget.name"
|
||||
class="text-xs opacity-80 min-w-[4em] truncate"
|
||||
>{{ widget.name }}</label
|
||||
>
|
||||
<!-- Group select and folder button together on the right -->
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- TODO: finish once we finish value bindings with Litegraph -->
|
||||
<Select
|
||||
:model-value="selectedFile?.name"
|
||||
:options="[selectedFile?.name || '']"
|
||||
:disabled="true"
|
||||
v-bind="transformCompatProps"
|
||||
class="min-w-[8em] max-w-[20em] text-xs"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs'
|
||||
}"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-folder"
|
||||
size="small"
|
||||
class="!w-8 !h-8"
|
||||
:disabled="readonly"
|
||||
@click="triggerFileInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image preview -->
|
||||
<!-- TODO: change hardcoded colors when design system incorporated -->
|
||||
<div class="relative group">
|
||||
<img :src="imageUrl" :alt="selectedFile?.name" class="w-full h-auto" />
|
||||
<!-- Darkening overlay on hover -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-200 pointer-events-none"
|
||||
/>
|
||||
<!-- 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 -->
|
||||
<button
|
||||
class="w-6 h-6 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none"
|
||||
style="background-color: #262729"
|
||||
@click="handleEdit"
|
||||
>
|
||||
<i class="pi pi-pencil text-white text-xs"></i>
|
||||
</button>
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
class="w-6 h-6 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none"
|
||||
style="background-color: #262729"
|
||||
@click="clearFile"
|
||||
>
|
||||
<i class="pi pi-times text-white text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio preview when audio file is loaded -->
|
||||
<div
|
||||
v-else-if="hasAudioFile"
|
||||
class="relative -mx-2"
|
||||
style="width: calc(100% + 1rem)"
|
||||
>
|
||||
<!-- Select section above audio player -->
|
||||
<div class="flex items-center justify-between gap-4 mb-2 px-2">
|
||||
<label
|
||||
v-if="widget.name"
|
||||
class="text-xs opacity-80 min-w-[4em] truncate"
|
||||
>{{ widget.name }}</label
|
||||
>
|
||||
<!-- Group select and folder button together on the right -->
|
||||
<div class="flex items-center gap-1">
|
||||
<Select
|
||||
:model-value="selectedFile?.name"
|
||||
:options="[selectedFile?.name || '']"
|
||||
:disabled="true"
|
||||
v-bind="transformCompatProps"
|
||||
class="min-w-[8em] max-w-[20em] text-xs"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs'
|
||||
}"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-folder"
|
||||
size="small"
|
||||
class="!w-8 !h-8"
|
||||
:disabled="readonly"
|
||||
@click="triggerFileInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio player -->
|
||||
<div class="relative group px-2">
|
||||
<div
|
||||
class="bg-[#1a1b1e] rounded-lg p-4 flex items-center gap-4"
|
||||
style="border: 1px solid #262729"
|
||||
>
|
||||
<!-- Audio icon -->
|
||||
<div class="flex-shrink-0">
|
||||
<i class="pi pi-volume-up text-2xl opacity-60"></i>
|
||||
</div>
|
||||
|
||||
<!-- File info and controls -->
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium mb-1">{{ selectedFile?.name }}</div>
|
||||
<div class="text-xs opacity-60">
|
||||
{{
|
||||
selectedFile ? (selectedFile.size / 1024).toFixed(1) + ' KB' : ''
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control buttons -->
|
||||
<div v-if="!readonly" 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]"
|
||||
@click="clearFile"
|
||||
>
|
||||
<i class="pi pi-times text-white text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show normal file upload UI when no image or audio is loaded -->
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col gap-1 w-full border border-solid p-1 rounded-lg"
|
||||
:style="{ borderColor: '#262729' }"
|
||||
>
|
||||
<div
|
||||
class="border border-dashed p-1 rounded-md transition-colors duration-200 hover:border-[#5B5E7D]"
|
||||
:style="{ borderColor: '#262729' }"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2 w-full py-4">
|
||||
<span class="text-xs opacity-60"> {{ $t('Drop your file or') }} </span>
|
||||
<div>
|
||||
<Button
|
||||
label="Browse Files"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="text-xs"
|
||||
:disabled="readonly"
|
||||
@click="triggerFileInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hidden file input always available for both states -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
class="hidden"
|
||||
:accept="widget.options?.accept"
|
||||
:multiple="false"
|
||||
:disabled="readonly"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Select from 'primevue/select'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly = false
|
||||
} = defineProps<{
|
||||
widget: SimplifiedWidget<File[] | null>
|
||||
modelValue: File[] | null
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: File[] | null]
|
||||
}>()
|
||||
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue: null,
|
||||
emit
|
||||
})
|
||||
|
||||
// Transform compatibility props for overlay positioning
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Since we only support single file, get the first file
|
||||
const selectedFile = computed(() => {
|
||||
const files = localValue.value || []
|
||||
return files.length > 0 ? files[0] : null
|
||||
})
|
||||
|
||||
// Quick file type detection for testing
|
||||
const detectFileType = (file: File) => {
|
||||
const type = file.type?.toLowerCase() || ''
|
||||
const name = file.name?.toLowerCase() || ''
|
||||
|
||||
if (
|
||||
type.startsWith('image/') ||
|
||||
name.match(/\.(jpg|jpeg|png|gif|webp|svg)$/)
|
||||
) {
|
||||
return 'image'
|
||||
}
|
||||
if (type.startsWith('video/') || name.match(/\.(mp4|webm|ogg|mov)$/)) {
|
||||
return 'video'
|
||||
}
|
||||
if (type.startsWith('audio/') || name.match(/\.(mp3|wav|ogg|flac)$/)) {
|
||||
return 'audio'
|
||||
}
|
||||
if (type === 'application/pdf' || name.endsWith('.pdf')) {
|
||||
return 'pdf'
|
||||
}
|
||||
if (type.includes('zip') || name.match(/\.(zip|rar|7z|tar|gz)$/)) {
|
||||
return 'archive'
|
||||
}
|
||||
return 'file'
|
||||
}
|
||||
|
||||
// Check if we have an image file
|
||||
const hasImageFile = computed(() => {
|
||||
return selectedFile.value && detectFileType(selectedFile.value) === 'image'
|
||||
})
|
||||
|
||||
// Check if we have an audio file
|
||||
const hasAudioFile = computed(() => {
|
||||
return selectedFile.value && detectFileType(selectedFile.value) === 'audio'
|
||||
})
|
||||
|
||||
// Get image URL for preview
|
||||
const imageUrl = computed(() => {
|
||||
if (hasImageFile.value && selectedFile.value) {
|
||||
return URL.createObjectURL(selectedFile.value)
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
// // Get audio URL for playback
|
||||
// const audioUrl = computed(() => {
|
||||
// if (hasAudioFile.value && selectedFile.value) {
|
||||
// return URL.createObjectURL(selectedFile.value)
|
||||
// }
|
||||
// return ''
|
||||
// })
|
||||
|
||||
// Clean up image URL when file changes
|
||||
watch(imageUrl, (newUrl, oldUrl) => {
|
||||
if (oldUrl && oldUrl !== newUrl) {
|
||||
URL.revokeObjectURL(oldUrl)
|
||||
}
|
||||
})
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (!readonly && target.files && target.files.length > 0) {
|
||||
// Since we only support single file, take the first one
|
||||
const file = target.files[0]
|
||||
|
||||
// Use the composable's onChange handler with an array
|
||||
onChange([file])
|
||||
|
||||
// Reset input to allow selecting same file again
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const clearFile = () => {
|
||||
// Clear the file
|
||||
onChange(null)
|
||||
|
||||
// Reset file input
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
// TODO: hook up with maskeditor
|
||||
}
|
||||
|
||||
// Clear file input when value is cleared externally
|
||||
watch(localValue, (newValue) => {
|
||||
if (!newValue || newValue.length === 0) {
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Clean up image URL on unmount
|
||||
onUnmounted(() => {
|
||||
if (imageUrl.value) {
|
||||
URL.revokeObjectURL(imageUrl.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,443 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Galleria from 'primevue/galleria'
|
||||
import type { GalleriaProps } from 'primevue/galleria'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetGalleria, {
|
||||
type GalleryImage,
|
||||
type GalleryValue
|
||||
} from './WidgetGalleria.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
'Gallery image': 'Gallery image'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test data constants for better test isolation
|
||||
const TEST_IMAGES_SMALL: readonly string[] = Object.freeze([
|
||||
'https://example.com/image0.jpg',
|
||||
'https://example.com/image1.jpg',
|
||||
'https://example.com/image2.jpg'
|
||||
])
|
||||
|
||||
const TEST_IMAGES_SINGLE: readonly string[] = Object.freeze([
|
||||
'https://example.com/single.jpg'
|
||||
])
|
||||
|
||||
const TEST_IMAGE_OBJECTS: readonly GalleryImage[] = Object.freeze([
|
||||
{
|
||||
itemImageSrc: 'https://example.com/image0.jpg',
|
||||
thumbnailImageSrc: 'https://example.com/thumb0.jpg',
|
||||
alt: 'Test image 0'
|
||||
},
|
||||
{
|
||||
itemImageSrc: 'https://example.com/image1.jpg',
|
||||
thumbnailImageSrc: 'https://example.com/thumb1.jpg',
|
||||
alt: 'Test image 1'
|
||||
}
|
||||
])
|
||||
|
||||
// Helper functions outside describe blocks for better clarity
|
||||
function createMockWidget(
|
||||
value: GalleryValue = [],
|
||||
options: Partial<GalleriaProps> = {}
|
||||
): SimplifiedWidget<GalleryValue> {
|
||||
return {
|
||||
name: 'test_galleria',
|
||||
type: 'array',
|
||||
value,
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
function mountComponent(
|
||||
widget: SimplifiedWidget<GalleryValue>,
|
||||
modelValue: GalleryValue,
|
||||
readonly = false
|
||||
) {
|
||||
return mount(WidgetGalleria, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
components: { Galleria }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
readonly,
|
||||
modelValue
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createImageStrings(count: number): string[] {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) => `https://example.com/image${i}.jpg`
|
||||
)
|
||||
}
|
||||
|
||||
// Factory function that takes images, creates widget internally, returns wrapper
|
||||
function createGalleriaWrapper(
|
||||
images: GalleryValue,
|
||||
options: Partial<GalleriaProps> = {},
|
||||
readonly = false
|
||||
) {
|
||||
const widget = createMockWidget(images, options)
|
||||
return mountComponent(widget, images, readonly)
|
||||
}
|
||||
|
||||
describe('WidgetGalleria Image Display', () => {
|
||||
// Group tests using the readonly constants where appropriate
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('renders galleria component', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
expect(galleria.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays empty gallery when no images provided', () => {
|
||||
const widget = createMockWidget([])
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
expect(galleria.props('value')).toEqual([])
|
||||
})
|
||||
|
||||
it('handles null or undefined value gracefully', () => {
|
||||
const widget = createMockWidget([])
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
expect(galleria.props('value')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('String Array Input', () => {
|
||||
it('converts string array to image objects', () => {
|
||||
const widget = createMockWidget([...TEST_IMAGES_SMALL])
|
||||
const wrapper = mountComponent(widget, [...TEST_IMAGES_SMALL])
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
const value = galleria.props('value')
|
||||
|
||||
expect(value).toHaveLength(3)
|
||||
expect(value[0]).toEqual({
|
||||
itemImageSrc: 'https://example.com/image0.jpg',
|
||||
thumbnailImageSrc: 'https://example.com/image0.jpg',
|
||||
alt: 'Image 0'
|
||||
})
|
||||
})
|
||||
|
||||
it('handles single string image', () => {
|
||||
const widget = createMockWidget([...TEST_IMAGES_SINGLE])
|
||||
const wrapper = mountComponent(widget, [...TEST_IMAGES_SINGLE])
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
const value = galleria.props('value')
|
||||
|
||||
expect(value).toHaveLength(1)
|
||||
expect(value[0]).toEqual({
|
||||
itemImageSrc: 'https://example.com/single.jpg',
|
||||
thumbnailImageSrc: 'https://example.com/single.jpg',
|
||||
alt: 'Image 0'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Object Array Input', () => {
|
||||
it('preserves image objects as-is', () => {
|
||||
const widget = createMockWidget([...TEST_IMAGE_OBJECTS])
|
||||
const wrapper = mountComponent(widget, [...TEST_IMAGE_OBJECTS])
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
const value = galleria.props('value')
|
||||
|
||||
expect(value).toEqual([...TEST_IMAGE_OBJECTS])
|
||||
})
|
||||
|
||||
it('handles mixed object properties', () => {
|
||||
const images: GalleryImage[] = [
|
||||
{ src: 'https://example.com/image1.jpg', alt: 'First' },
|
||||
{ itemImageSrc: 'https://example.com/image2.jpg' },
|
||||
{ thumbnailImageSrc: 'https://example.com/thumb3.jpg' }
|
||||
]
|
||||
const widget = createMockWidget(images)
|
||||
const wrapper = mountComponent(widget, images)
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
const value = galleria.props('value')
|
||||
|
||||
expect(value).toEqual(images)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Thumbnail Display', () => {
|
||||
it('shows thumbnails when multiple images present', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
expect(galleria.props('showThumbnails')).toBe(true)
|
||||
})
|
||||
|
||||
it('hides thumbnails for single image', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
expect(galleria.props('showThumbnails')).toBe(false)
|
||||
})
|
||||
|
||||
it('respects widget option to hide thumbnails', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL], {
|
||||
showThumbnails: false
|
||||
})
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
expect(galleria.props('showThumbnails')).toBe(false)
|
||||
})
|
||||
|
||||
it('shows thumbnails when explicitly enabled for multiple images', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL], {
|
||||
showThumbnails: true
|
||||
})
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
expect(galleria.props('showThumbnails')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Navigation Buttons', () => {
|
||||
it('shows navigation buttons when multiple images present', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
expect(galleria.props('showItemNavigators')).toBe(true)
|
||||
})
|
||||
|
||||
it('hides navigation buttons for single image', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
expect(galleria.props('showItemNavigators')).toBe(false)
|
||||
})
|
||||
|
||||
it('respects widget option to hide navigation buttons', () => {
|
||||
const images = createImageStrings(3)
|
||||
const widget = createMockWidget(images, { showItemNavigators: false })
|
||||
const wrapper = mountComponent(widget, images)
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
expect(galleria.props('showItemNavigators')).toBe(false)
|
||||
})
|
||||
|
||||
it('shows navigation buttons when explicitly enabled for multiple images', () => {
|
||||
const images = createImageStrings(3)
|
||||
const widget = createMockWidget(images, { showItemNavigators: true })
|
||||
const wrapper = mountComponent(widget, images)
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
expect(galleria.props('showItemNavigators')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
const widget = createMockWidget(images, {
|
||||
circular: true,
|
||||
autoPlay: true,
|
||||
transitionInterval: 3000
|
||||
})
|
||||
const wrapper = mountComponent(widget, images)
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
expect(galleria.props('circular')).toBe(true)
|
||||
expect(galleria.props('autoPlay')).toBe(true)
|
||||
expect(galleria.props('transitionInterval')).toBe(3000)
|
||||
})
|
||||
|
||||
it('applies custom styling props', () => {
|
||||
const images = createImageStrings(2)
|
||||
const widget = createMockWidget(images)
|
||||
const wrapper = mountComponent(widget, images)
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
// Check that galleria has styling attributes rather than specific classes
|
||||
expect(galleria.attributes('class')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Active Index Management', () => {
|
||||
it('initializes with zero active index', () => {
|
||||
const images = createImageStrings(3)
|
||||
const widget = createMockWidget(images)
|
||||
const wrapper = mountComponent(widget, images)
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
expect(galleria.props('activeIndex')).toBe(0)
|
||||
})
|
||||
|
||||
it('can update active index', async () => {
|
||||
const images = createImageStrings(3)
|
||||
const widget = createMockWidget(images)
|
||||
const wrapper = mountComponent(widget, images)
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
await galleria.vm.$emit('update:activeIndex', 2)
|
||||
|
||||
// Check that the internal activeIndex ref was updated
|
||||
const vm = wrapper.vm as any
|
||||
expect(vm.activeIndex).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Image Template Rendering', () => {
|
||||
it('renders item template with correct image source priorities', () => {
|
||||
const images: GalleryImage[] = [
|
||||
{
|
||||
itemImageSrc: 'https://example.com/item.jpg',
|
||||
src: 'https://example.com/fallback.jpg'
|
||||
},
|
||||
{ src: 'https://example.com/only-src.jpg' }
|
||||
]
|
||||
const widget = createMockWidget(images)
|
||||
const wrapper = mountComponent(widget, images)
|
||||
|
||||
// The template logic should prioritize itemImageSrc > src > fallback to the item itself
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
expect(galleria.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders thumbnail template with correct image source priorities', () => {
|
||||
const images: GalleryImage[] = [
|
||||
{
|
||||
thumbnailImageSrc: 'https://example.com/thumb.jpg',
|
||||
src: 'https://example.com/fallback.jpg'
|
||||
},
|
||||
{ src: 'https://example.com/only-src.jpg' }
|
||||
]
|
||||
const widget = createMockWidget(images)
|
||||
const wrapper = mountComponent(widget, images)
|
||||
|
||||
// The template logic should prioritize thumbnailImageSrc > src > fallback to the item itself
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
expect(galleria.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles empty array gracefully', () => {
|
||||
const widget = createMockWidget([])
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
expect(galleria.props('value')).toEqual([])
|
||||
expect(galleria.props('showThumbnails')).toBe(false)
|
||||
expect(galleria.props('showItemNavigators')).toBe(false)
|
||||
})
|
||||
|
||||
it('handles malformed image objects', () => {
|
||||
const malformedImages = [
|
||||
{}, // Empty object
|
||||
{ randomProp: 'value' }, // Object without expected image properties
|
||||
null, // Null value
|
||||
undefined // Undefined value
|
||||
]
|
||||
const widget = createMockWidget(malformedImages as string[])
|
||||
const wrapper = mountComponent(widget, malformedImages as string[])
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
// Null/undefined should be filtered out, leaving only the objects
|
||||
const expectedValue = [{}, { randomProp: 'value' }]
|
||||
expect(galleria.props('value')).toEqual(expectedValue)
|
||||
})
|
||||
|
||||
it('handles very large image arrays', () => {
|
||||
const largeImageArray = createImageStrings(100)
|
||||
const widget = createMockWidget(largeImageArray)
|
||||
const wrapper = mountComponent(widget, largeImageArray)
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
expect(galleria.props('value')).toHaveLength(100)
|
||||
expect(galleria.props('showThumbnails')).toBe(true)
|
||||
expect(galleria.props('showItemNavigators')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles mixed string and object arrays gracefully', () => {
|
||||
// This is technically invalid input, but the component should handle it
|
||||
const mixedArray = [
|
||||
'https://example.com/string.jpg',
|
||||
{ itemImageSrc: 'https://example.com/object.jpg' },
|
||||
'https://example.com/another-string.jpg'
|
||||
]
|
||||
const widget = createMockWidget(mixedArray as string[])
|
||||
|
||||
// The component expects consistent typing, but let's test it handles mixed input
|
||||
expect(() => mountComponent(widget, mixedArray as string[])).not.toThrow()
|
||||
})
|
||||
|
||||
it('handles invalid URL strings', () => {
|
||||
const invalidUrls = ['not-a-url', '', ' ', 'http://', 'ftp://invalid']
|
||||
const widget = createMockWidget(invalidUrls)
|
||||
const wrapper = mountComponent(widget, invalidUrls)
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
expect(galleria.props('value')).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling and Layout', () => {
|
||||
it('applies max-width constraint', () => {
|
||||
const images = createImageStrings(2)
|
||||
const widget = createMockWidget(images)
|
||||
const wrapper = mountComponent(widget, images)
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
// Check that component has styling applied rather than specific classes
|
||||
expect(galleria.attributes('class')).toBeDefined()
|
||||
})
|
||||
|
||||
it('applies passthrough props for thumbnails', () => {
|
||||
const images = createImageStrings(3)
|
||||
const widget = createMockWidget(images)
|
||||
const wrapper = mountComponent(widget, images)
|
||||
|
||||
const galleria = wrapper.findComponent({ name: 'Galleria' })
|
||||
const pt = galleria.props('pt')
|
||||
|
||||
expect(pt).toBeDefined()
|
||||
expect(pt.thumbnails).toBeDefined()
|
||||
expect(pt.thumbnailContent).toBeDefined()
|
||||
expect(pt.thumbnailPrevButton).toBeDefined()
|
||||
expect(pt.thumbnailNextButton).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Galleria
|
||||
v-model:active-index="activeIndex"
|
||||
:value="galleryImages"
|
||||
v-bind="filteredProps"
|
||||
:show-thumbnails="showThumbnails"
|
||||
:show-item-navigators="showNavButtons"
|
||||
class="max-w-full"
|
||||
:pt="{
|
||||
thumbnails: {
|
||||
class: 'overflow-hidden'
|
||||
},
|
||||
thumbnailContent: {
|
||||
class: 'py-4 px-2'
|
||||
},
|
||||
thumbnailPrevButton: {
|
||||
class: 'm-0'
|
||||
},
|
||||
thumbnailNextButton: {
|
||||
class: 'm-0'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<img
|
||||
:src="item?.itemImageSrc || item?.src || ''"
|
||||
:alt="
|
||||
item?.alt ||
|
||||
`${t('g.galleryImage')} ${activeIndex + 1} of ${galleryImages.length}`
|
||||
"
|
||||
class="w-full h-auto max-h-64 object-contain"
|
||||
/>
|
||||
</template>
|
||||
<template #thumbnail="{ item }">
|
||||
<div class="p-1 w-full h-full">
|
||||
<img
|
||||
:src="item?.thumbnailImageSrc || item?.src || ''"
|
||||
:alt="
|
||||
item?.alt ||
|
||||
`${t('g.galleryThumbnail')} ${galleryImages.findIndex((img) => img === item) + 1} of ${galleryImages.length}`
|
||||
"
|
||||
class="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Galleria>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Galleria from 'primevue/galleria'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
GALLERIA_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
export interface GalleryImage {
|
||||
itemImageSrc?: string
|
||||
thumbnailImageSrc?: string
|
||||
src?: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export type GalleryValue = string[] | GalleryImage[]
|
||||
|
||||
const value = defineModel<GalleryValue>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<GalleryValue>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const activeIndex = ref(0)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, GALLERIA_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
const galleryImages = computed(() => {
|
||||
if (!value.value || !Array.isArray(value.value)) return []
|
||||
|
||||
return value.value
|
||||
.filter((item) => item !== null && item !== undefined) // Filter out null/undefined
|
||||
.map((item, index) => {
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
itemImageSrc: item,
|
||||
thumbnailImageSrc: item,
|
||||
alt: `Image ${index}`
|
||||
}
|
||||
}
|
||||
return item ?? {} // Ensure we have at least an empty object
|
||||
})
|
||||
})
|
||||
|
||||
const showThumbnails = computed(() => {
|
||||
return (
|
||||
props.widget.options?.showThumbnails !== false &&
|
||||
galleryImages.value.length > 1
|
||||
)
|
||||
})
|
||||
|
||||
const showNavButtons = computed(() => {
|
||||
return (
|
||||
props.widget.options?.showItemNavigators !== false &&
|
||||
galleryImages.value.length > 1
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Ensure thumbnail container doesn't overflow */
|
||||
:deep(.p-galleria-thumbnails) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Constrain thumbnail items to prevent overlap */
|
||||
:deep(.p-galleria-thumbnail-item) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Ensure thumbnail wrapper maintains aspect ratio */
|
||||
:deep(.p-galleria-thumbnail) {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,337 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ImageCompare from 'primevue/imagecompare'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetImageCompare, {
|
||||
type ImageCompareValue
|
||||
} from './WidgetImageCompare.vue'
|
||||
|
||||
describe('WidgetImageCompare Display', () => {
|
||||
const createMockWidget = (
|
||||
value: ImageCompareValue | string,
|
||||
options: SimplifiedWidget['options'] = {}
|
||||
): SimplifiedWidget<ImageCompareValue | string> => ({
|
||||
name: 'test_imagecompare',
|
||||
type: 'object',
|
||||
value,
|
||||
options
|
||||
})
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<ImageCompareValue | string>,
|
||||
readonly = false
|
||||
) => {
|
||||
return mount(WidgetImageCompare, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { ImageCompare }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
readonly
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('renders imagecompare component with proper structure and styling', () => {
|
||||
const value: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: 'https://example.com/after.jpg'
|
||||
}
|
||||
const widget = createMockWidget(value)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
// Component exists
|
||||
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
|
||||
expect(imageCompare.exists()).toBe(true)
|
||||
|
||||
// Renders both images with correct URLs
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images).toHaveLength(2)
|
||||
expect(images[0].attributes('src')).toBe('https://example.com/before.jpg')
|
||||
expect(images[1].attributes('src')).toBe('https://example.com/after.jpg')
|
||||
|
||||
// Images have proper styling classes
|
||||
images.forEach((img) => {
|
||||
expect(img.classes()).toContain('object-cover')
|
||||
expect(img.classes()).toContain('w-full')
|
||||
expect(img.classes()).toContain('h-full')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Object Value Input', () => {
|
||||
it('handles alt text correctly - custom, default, and empty', () => {
|
||||
// Test custom alt text
|
||||
const customAltValue: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: 'https://example.com/after.jpg',
|
||||
beforeAlt: 'Original design',
|
||||
afterAlt: 'Updated design'
|
||||
}
|
||||
const customWrapper = mountComponent(createMockWidget(customAltValue))
|
||||
const customImages = customWrapper.findAll('img')
|
||||
expect(customImages[0].attributes('alt')).toBe('Original design')
|
||||
expect(customImages[1].attributes('alt')).toBe('Updated design')
|
||||
|
||||
// Test default alt text
|
||||
const defaultAltValue: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: 'https://example.com/after.jpg'
|
||||
}
|
||||
const defaultWrapper = mountComponent(createMockWidget(defaultAltValue))
|
||||
const defaultImages = defaultWrapper.findAll('img')
|
||||
expect(defaultImages[0].attributes('alt')).toBe('Before image')
|
||||
expect(defaultImages[1].attributes('alt')).toBe('After image')
|
||||
|
||||
// Test empty string alt text (falls back to default)
|
||||
const emptyAltValue: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: 'https://example.com/after.jpg',
|
||||
beforeAlt: '',
|
||||
afterAlt: ''
|
||||
}
|
||||
const emptyWrapper = mountComponent(createMockWidget(emptyAltValue))
|
||||
const emptyImages = emptyWrapper.findAll('img')
|
||||
expect(emptyImages[0].attributes('alt')).toBe('Before image')
|
||||
expect(emptyImages[1].attributes('alt')).toBe('After image')
|
||||
})
|
||||
|
||||
it('handles missing and partial image URLs gracefully', () => {
|
||||
// Missing URLs
|
||||
const missingValue: ImageCompareValue = { before: '', after: '' }
|
||||
const missingWrapper = mountComponent(createMockWidget(missingValue))
|
||||
const missingImages = missingWrapper.findAll('img')
|
||||
expect(missingImages[0].attributes('src')).toBe('')
|
||||
expect(missingImages[1].attributes('src')).toBe('')
|
||||
|
||||
// Partial URLs
|
||||
const partialValue: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: ''
|
||||
}
|
||||
const partialWrapper = mountComponent(createMockWidget(partialValue))
|
||||
const partialImages = partialWrapper.findAll('img')
|
||||
expect(partialImages[0].attributes('src')).toBe(
|
||||
'https://example.com/before.jpg'
|
||||
)
|
||||
expect(partialImages[1].attributes('src')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('String Value Input', () => {
|
||||
it('handles string value as before image only', () => {
|
||||
const value = 'https://example.com/single.jpg'
|
||||
const widget = createMockWidget(value)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[0].attributes('src')).toBe('https://example.com/single.jpg')
|
||||
expect(images[1].attributes('src')).toBe('')
|
||||
})
|
||||
|
||||
it('uses default alt text for string values', () => {
|
||||
const value = 'https://example.com/single.jpg'
|
||||
const widget = createMockWidget(value)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[0].attributes('alt')).toBe('Before image')
|
||||
expect(images[1].attributes('alt')).toBe('After image')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Widget Options Handling', () => {
|
||||
it('passes through accessibility options', () => {
|
||||
const value: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: 'https://example.com/after.jpg'
|
||||
}
|
||||
const widget = createMockWidget(value, {
|
||||
tabindex: 1,
|
||||
ariaLabel: 'Compare images',
|
||||
ariaLabelledby: 'compare-label'
|
||||
})
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
|
||||
expect(imageCompare.props('tabindex')).toBe(1)
|
||||
expect(imageCompare.props('ariaLabel')).toBe('Compare images')
|
||||
expect(imageCompare.props('ariaLabelledby')).toBe('compare-label')
|
||||
})
|
||||
|
||||
it('uses default tabindex when not provided', () => {
|
||||
const value: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: 'https://example.com/after.jpg'
|
||||
}
|
||||
const widget = createMockWidget(value)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
|
||||
expect(imageCompare.props('tabindex')).toBe(0)
|
||||
})
|
||||
|
||||
it('passes through PrimeVue specific options', () => {
|
||||
const value: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: 'https://example.com/after.jpg'
|
||||
}
|
||||
const widget = createMockWidget(value, {
|
||||
unstyled: true,
|
||||
pt: { root: { class: 'custom-class' } },
|
||||
ptOptions: { mergeSections: true }
|
||||
})
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
|
||||
expect(imageCompare.props('unstyled')).toBe(true)
|
||||
expect(imageCompare.props('pt')).toEqual({
|
||||
root: { class: 'custom-class' }
|
||||
})
|
||||
expect(imageCompare.props('ptOptions')).toEqual({ mergeSections: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Readonly Mode', () => {
|
||||
it('renders normally in readonly mode (no interaction restrictions)', () => {
|
||||
const value: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: 'https://example.com/after.jpg'
|
||||
}
|
||||
const widget = createMockWidget(value)
|
||||
const wrapper = mountComponent(widget, true)
|
||||
|
||||
// ImageCompare is display-only, readonly doesn't affect rendering
|
||||
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
|
||||
expect(imageCompare.exists()).toBe(true)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles null or undefined widget value', () => {
|
||||
const widget = createMockWidget('')
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[0].attributes('src')).toBe('')
|
||||
expect(images[1].attributes('src')).toBe('')
|
||||
expect(images[0].attributes('alt')).toBe('Before image')
|
||||
expect(images[1].attributes('alt')).toBe('After image')
|
||||
})
|
||||
|
||||
it('handles empty object value', () => {
|
||||
const value: ImageCompareValue = {} as ImageCompareValue
|
||||
const widget = createMockWidget(value)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[0].attributes('src')).toBe('')
|
||||
expect(images[1].attributes('src')).toBe('')
|
||||
})
|
||||
|
||||
it('handles malformed object value', () => {
|
||||
const value = { randomProp: 'test', before: '', after: '' }
|
||||
const widget = createMockWidget(value)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[0].attributes('src')).toBe('')
|
||||
expect(images[1].attributes('src')).toBe('')
|
||||
})
|
||||
|
||||
it('handles special content - long URLs, special characters, and long alt text', () => {
|
||||
// Test very long URLs
|
||||
const longUrl = 'https://example.com/' + 'a'.repeat(1000) + '.jpg'
|
||||
const longUrlValue: ImageCompareValue = {
|
||||
before: longUrl,
|
||||
after: longUrl
|
||||
}
|
||||
const longUrlWrapper = mountComponent(createMockWidget(longUrlValue))
|
||||
const longUrlImages = longUrlWrapper.findAll('img')
|
||||
expect(longUrlImages[0].attributes('src')).toBe(longUrl)
|
||||
expect(longUrlImages[1].attributes('src')).toBe(longUrl)
|
||||
|
||||
// Test special characters in URLs
|
||||
const specialUrl =
|
||||
'https://example.com/path with spaces & symbols!@#$.jpg'
|
||||
const specialUrlValue: ImageCompareValue = {
|
||||
before: specialUrl,
|
||||
after: specialUrl
|
||||
}
|
||||
const specialUrlWrapper = mountComponent(
|
||||
createMockWidget(specialUrlValue)
|
||||
)
|
||||
const specialUrlImages = specialUrlWrapper.findAll('img')
|
||||
expect(specialUrlImages[0].attributes('src')).toBe(specialUrl)
|
||||
expect(specialUrlImages[1].attributes('src')).toBe(specialUrl)
|
||||
|
||||
// Test very long alt text
|
||||
const longAlt =
|
||||
'Very long alt text that exceeds normal length: ' +
|
||||
'description '.repeat(50)
|
||||
const longAltValue: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: 'https://example.com/after.jpg',
|
||||
beforeAlt: longAlt,
|
||||
afterAlt: longAlt
|
||||
}
|
||||
const longAltWrapper = mountComponent(createMockWidget(longAltValue))
|
||||
const longAltImages = longAltWrapper.findAll('img')
|
||||
expect(longAltImages[0].attributes('alt')).toBe(longAlt)
|
||||
expect(longAltImages[1].attributes('alt')).toBe(longAlt)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Template Structure', () => {
|
||||
it('correctly assigns images to left and right template slots', () => {
|
||||
const value: ImageCompareValue = {
|
||||
before: 'https://example.com/before.jpg',
|
||||
after: 'https://example.com/after.jpg'
|
||||
}
|
||||
const widget = createMockWidget(value)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
// First image (before) should be in left template slot
|
||||
expect(images[0].attributes('src')).toBe('https://example.com/before.jpg')
|
||||
// Second image (after) should be in right template slot
|
||||
expect(images[1].attributes('src')).toBe('https://example.com/after.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration', () => {
|
||||
it('works with various URL types - data URLs and blob URLs', () => {
|
||||
// Test data URLs
|
||||
const dataUrl =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
|
||||
const dataUrlValue: ImageCompareValue = {
|
||||
before: dataUrl,
|
||||
after: dataUrl
|
||||
}
|
||||
const dataUrlWrapper = mountComponent(createMockWidget(dataUrlValue))
|
||||
const dataUrlImages = dataUrlWrapper.findAll('img')
|
||||
expect(dataUrlImages[0].attributes('src')).toBe(dataUrl)
|
||||
expect(dataUrlImages[1].attributes('src')).toBe(dataUrl)
|
||||
|
||||
// Test blob URLs
|
||||
const blobUrl =
|
||||
'blob:http://example.com/12345678-1234-1234-1234-123456789012'
|
||||
const blobUrlValue: ImageCompareValue = {
|
||||
before: blobUrl,
|
||||
after: blobUrl
|
||||
}
|
||||
const blobUrlWrapper = mountComponent(createMockWidget(blobUrlValue))
|
||||
const blobUrlImages = blobUrlWrapper.findAll('img')
|
||||
expect(blobUrlImages[0].attributes('src')).toBe(blobUrl)
|
||||
expect(blobUrlImages[1].attributes('src')).toBe(blobUrl)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<ImageCompare
|
||||
:tabindex="widget.options?.tabindex ?? 0"
|
||||
:aria-label="widget.options?.ariaLabel"
|
||||
:aria-labelledby="widget.options?.ariaLabelledby"
|
||||
:pt="widget.options?.pt"
|
||||
:pt-options="widget.options?.ptOptions"
|
||||
:unstyled="widget.options?.unstyled"
|
||||
>
|
||||
<template #left>
|
||||
<img
|
||||
:src="beforeImage"
|
||||
:alt="beforeAlt"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</template>
|
||||
<template #right>
|
||||
<img
|
||||
:src="afterImage"
|
||||
:alt="afterAlt"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</template>
|
||||
</ImageCompare>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ImageCompare from 'primevue/imagecompare'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
export interface ImageCompareValue {
|
||||
before: string
|
||||
after: string
|
||||
beforeAlt?: string
|
||||
afterAlt?: string
|
||||
initialPosition?: number
|
||||
}
|
||||
|
||||
// Image compare widgets typically don't have v-model, they display comparison
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<ImageCompareValue | string>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const beforeImage = computed(() => {
|
||||
const value = props.widget.value
|
||||
return typeof value === 'string' ? value : value?.before || ''
|
||||
})
|
||||
|
||||
const afterImage = computed(() => {
|
||||
const value = props.widget.value
|
||||
return typeof value === 'string' ? '' : value?.after || ''
|
||||
})
|
||||
|
||||
const beforeAlt = computed(() => {
|
||||
const value = props.widget.value
|
||||
return typeof value === 'object' && value?.beforeAlt
|
||||
? value.beforeAlt
|
||||
: 'Before image'
|
||||
})
|
||||
|
||||
const afterAlt = computed(() => {
|
||||
const value = props.widget.value
|
||||
return typeof value === 'object' && value?.afterAlt
|
||||
? value.afterAlt
|
||||
: 'After image'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
|
||||
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
|
||||
|
||||
defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ default: 0 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="
|
||||
widget.type === 'slider'
|
||||
? WidgetInputNumberSlider
|
||||
: WidgetInputNumberInput
|
||||
"
|
||||
v-model="modelValue"
|
||||
:widget="widget"
|
||||
:readonly="readonly"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,353 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
|
||||
|
||||
function createMockWidget(
|
||||
value: number = 0,
|
||||
type: 'int' | 'float' = 'int',
|
||||
options: SimplifiedWidget['options'] = {},
|
||||
callback?: (value: number) => void
|
||||
): SimplifiedWidget<number> {
|
||||
return {
|
||||
name: 'test_input_number',
|
||||
type,
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
}
|
||||
}
|
||||
|
||||
function mountComponent(
|
||||
widget: SimplifiedWidget<number>,
|
||||
modelValue: number,
|
||||
readonly = false
|
||||
) {
|
||||
return mount(WidgetInputNumberInput, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { InputNumber }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getNumberInput(wrapper: ReturnType<typeof mount>) {
|
||||
const input = wrapper.get<HTMLInputElement>('input[inputmode="numeric"]')
|
||||
return input.element
|
||||
}
|
||||
|
||||
describe('WidgetInputNumberInput Value Binding', () => {
|
||||
it('displays initial value in input field', () => {
|
||||
const widget = createMockWidget(42, 'int')
|
||||
const wrapper = mountComponent(widget, 42)
|
||||
|
||||
const input = getNumberInput(wrapper)
|
||||
expect(input.value).toBe('42')
|
||||
})
|
||||
|
||||
it('emits update:modelValue when value changes', async () => {
|
||||
const widget = createMockWidget(10, 'int')
|
||||
const wrapper = mountComponent(widget, 10)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
await inputNumber.vm.$emit('update:modelValue', 20)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(20)
|
||||
})
|
||||
|
||||
it('handles negative values', () => {
|
||||
const widget = createMockWidget(-5, 'int')
|
||||
const wrapper = mountComponent(widget, -5)
|
||||
|
||||
const input = getNumberInput(wrapper)
|
||||
expect(input.value).toBe('-5')
|
||||
})
|
||||
|
||||
it('handles decimal values for float type', () => {
|
||||
const widget = createMockWidget(3.14, 'float')
|
||||
const wrapper = mountComponent(widget, 3.14)
|
||||
|
||||
const input = getNumberInput(wrapper)
|
||||
expect(input.value).toBe('3.14')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetInputNumberInput Component Rendering', () => {
|
||||
it('renders InputNumber component with show-buttons', () => {
|
||||
const widget = createMockWidget(5, 'int')
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
expect(inputNumber.exists()).toBe(true)
|
||||
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)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
expect(inputNumber.props('buttonLayout')).toBe('horizontal')
|
||||
})
|
||||
|
||||
it('sets size to small', () => {
|
||||
const widget = createMockWidget(5, 'int')
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
expect(inputNumber.props('size')).toBe('small')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetInputNumberInput Step Value', () => {
|
||||
it('defaults to 0 for unrestricted stepping', () => {
|
||||
const widget = createMockWidget(5, 'int')
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
expect(inputNumber.props('step')).toBe(0)
|
||||
})
|
||||
|
||||
it('uses step2 value when provided', () => {
|
||||
const widget = createMockWidget(5, 'int', { step2: 0.5 })
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
expect(inputNumber.props('step')).toBe(0.5)
|
||||
})
|
||||
|
||||
it('calculates step from precision for precision 0', () => {
|
||||
const widget = createMockWidget(5, 'int', { precision: 0 })
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
expect(inputNumber.props('step')).toBe(1)
|
||||
})
|
||||
|
||||
it('calculates step from precision for precision 1', () => {
|
||||
const widget = createMockWidget(5, 'float', { precision: 1 })
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
expect(inputNumber.props('step')).toBe(0.1)
|
||||
})
|
||||
|
||||
it('calculates step from precision for precision 2', () => {
|
||||
const widget = createMockWidget(5, 'float', { precision: 2 })
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
expect(inputNumber.props('step')).toBe(0.01)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetInputNumberInput Grouping Behavior', () => {
|
||||
it('displays numbers without commas by default for int widgets', () => {
|
||||
const widget = createMockWidget(1000, 'int')
|
||||
const wrapper = mountComponent(widget, 1000)
|
||||
|
||||
const input = getNumberInput(wrapper)
|
||||
expect(input.value).toBe('1000')
|
||||
expect(input.value).not.toContain(',')
|
||||
})
|
||||
|
||||
it('displays numbers without commas by default for float widgets', () => {
|
||||
const widget = createMockWidget(1000.5, 'float')
|
||||
const wrapper = mountComponent(widget, 1000.5)
|
||||
|
||||
const input = getNumberInput(wrapper)
|
||||
expect(input.value).toBe('1000.5')
|
||||
expect(input.value).not.toContain(',')
|
||||
})
|
||||
|
||||
it('displays numbers with commas when grouping enabled', () => {
|
||||
const widget = createMockWidget(1000, 'int', { useGrouping: true })
|
||||
const wrapper = mountComponent(widget, 1000)
|
||||
|
||||
const input = getNumberInput(wrapper)
|
||||
expect(input.value).toBe('1,000')
|
||||
expect(input.value).toContain(',')
|
||||
})
|
||||
|
||||
it('displays numbers without commas when grouping explicitly disabled', () => {
|
||||
const widget = createMockWidget(1000, 'int', { useGrouping: false })
|
||||
const wrapper = mountComponent(widget, 1000)
|
||||
|
||||
const input = getNumberInput(wrapper)
|
||||
expect(input.value).toBe('1000')
|
||||
expect(input.value).not.toContain(',')
|
||||
})
|
||||
|
||||
it('displays numbers without commas when useGrouping option is undefined', () => {
|
||||
const widget = createMockWidget(1000, 'int', { useGrouping: undefined })
|
||||
const wrapper = mountComponent(widget, 1000)
|
||||
|
||||
const input = getNumberInput(wrapper)
|
||||
expect(input.value).toBe('1000')
|
||||
expect(input.value).not.toContain(',')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetInputNumberInput Large Integer Precision Handling', () => {
|
||||
const SAFE_INTEGER_MAX = Number.MAX_SAFE_INTEGER // 9,007,199,254,740,991
|
||||
const UNSAFE_LARGE_INTEGER = 18446744073709552000 // Example seed value that exceeds safe range
|
||||
|
||||
it('shows buttons for safe integer values', () => {
|
||||
const widget = createMockWidget(1000, 'int')
|
||||
const wrapper = mountComponent(widget, 1000)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
expect(inputNumber.props('showButtons')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows buttons for values at safe integer limit', () => {
|
||||
const widget = createMockWidget(SAFE_INTEGER_MAX, 'int')
|
||||
const wrapper = mountComponent(widget, SAFE_INTEGER_MAX)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
expect(inputNumber.props('showButtons')).toBe(true)
|
||||
})
|
||||
|
||||
it('hides buttons for unsafe large integer values', () => {
|
||||
const widget = createMockWidget(UNSAFE_LARGE_INTEGER, 'int')
|
||||
const wrapper = mountComponent(widget, UNSAFE_LARGE_INTEGER)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
expect(inputNumber.props('showButtons')).toBe(false)
|
||||
})
|
||||
|
||||
it('hides buttons for unsafe negative integer values', () => {
|
||||
const unsafeNegative = -UNSAFE_LARGE_INTEGER
|
||||
const widget = createMockWidget(unsafeNegative, 'int')
|
||||
const wrapper = mountComponent(widget, unsafeNegative)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
expect(inputNumber.props('showButtons')).toBe(false)
|
||||
})
|
||||
|
||||
it('shows tooltip for disabled buttons due to precision limits', () => {
|
||||
const widget = createMockWidget(UNSAFE_LARGE_INTEGER, 'int')
|
||||
const wrapper = mountComponent(widget, UNSAFE_LARGE_INTEGER)
|
||||
|
||||
// Check that tooltip wrapper div exists
|
||||
const tooltipDiv = wrapper.find('div[v-tooltip]')
|
||||
expect(tooltipDiv.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not show tooltip for safe integer values', () => {
|
||||
const widget = createMockWidget(1000, 'int')
|
||||
const wrapper = mountComponent(widget, 1000)
|
||||
|
||||
// For safe values, tooltip should not be set (computed returns null)
|
||||
const tooltipDiv = wrapper.find('div')
|
||||
expect(tooltipDiv.attributes('v-tooltip')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles edge case of zero value', () => {
|
||||
const widget = createMockWidget(0, 'int')
|
||||
const wrapper = mountComponent(widget, 0)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
expect(inputNumber.props('showButtons')).toBe(true)
|
||||
})
|
||||
|
||||
it('correctly identifies safe vs unsafe integers using Number.isSafeInteger', () => {
|
||||
// Test the JavaScript behavior our component relies on
|
||||
expect(Number.isSafeInteger(SAFE_INTEGER_MAX)).toBe(true)
|
||||
expect(Number.isSafeInteger(SAFE_INTEGER_MAX + 1)).toBe(false)
|
||||
expect(Number.isSafeInteger(UNSAFE_LARGE_INTEGER)).toBe(false)
|
||||
expect(Number.isSafeInteger(-SAFE_INTEGER_MAX)).toBe(true)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
expect(inputNumber.props('showButtons')).toBe(true)
|
||||
})
|
||||
|
||||
it('hides buttons for unsafe floating point values', () => {
|
||||
const unsafeFloat = UNSAFE_LARGE_INTEGER + 0.5
|
||||
const widget = createMockWidget(unsafeFloat, 'float')
|
||||
const wrapper = mountComponent(widget, unsafeFloat)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
expect(inputNumber.props('showButtons')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetInputNumberInput Edge Cases for Precision Handling', () => {
|
||||
it('handles null/undefined model values gracefully', () => {
|
||||
const widget = createMockWidget(0, 'int')
|
||||
// Mount with undefined as modelValue
|
||||
const wrapper = mount(WidgetInputNumberInput, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { InputNumber }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue: undefined as any
|
||||
}
|
||||
})
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
expect(inputNumber.props('showButtons')).toBe(true) // Should default to safe behavior
|
||||
})
|
||||
|
||||
it('handles NaN values gracefully', () => {
|
||||
const widget = createMockWidget(NaN, 'int')
|
||||
const wrapper = mountComponent(widget, NaN)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
// NaN is not a safe integer, so buttons should be hidden
|
||||
expect(inputNumber.props('showButtons')).toBe(false)
|
||||
})
|
||||
|
||||
it('handles Infinity values', () => {
|
||||
const widget = createMockWidget(Infinity, 'int')
|
||||
const wrapper = mountComponent(widget, Infinity)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
expect(inputNumber.props('showButtons')).toBe(false)
|
||||
})
|
||||
|
||||
it('handles negative Infinity values', () => {
|
||||
const widget = createMockWidget(-Infinity, 'int')
|
||||
const wrapper = mountComponent(widget, -Infinity)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
expect(inputNumber.props('showButtons')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useNumberWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
INPUT_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
modelValue: number
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number]
|
||||
}>()
|
||||
|
||||
const { localValue, onChange } = useNumberWidgetValue(
|
||||
props.widget,
|
||||
props.modelValue,
|
||||
emit
|
||||
)
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
// Get the precision value for proper number formatting
|
||||
const precision = computed(() => {
|
||||
const p = props.widget.options?.precision
|
||||
// Treat negative or non-numeric precision as undefined
|
||||
return typeof p === 'number' && p >= 0 ? p : undefined
|
||||
})
|
||||
|
||||
// Calculate the step value based on precision or widget options
|
||||
const stepValue = computed(() => {
|
||||
// Use step2 (correct input spec value) instead of step (legacy 10x value)
|
||||
if (props.widget.options?.step2 !== undefined) {
|
||||
return Number(props.widget.options.step2)
|
||||
}
|
||||
// Otherwise, derive from precision
|
||||
if (precision.value !== undefined) {
|
||||
if (precision.value === 0) {
|
||||
return 1
|
||||
}
|
||||
// For precision > 0, step = 1 / (10^precision)
|
||||
// precision 1 → 0.1, precision 2 → 0.01, etc.
|
||||
return Number((1 / Math.pow(10, precision.value)).toFixed(precision.value))
|
||||
}
|
||||
// Default to 'any' for unrestricted stepping
|
||||
return 0
|
||||
})
|
||||
|
||||
// Disable grouping separators by default unless explicitly enabled by the node author
|
||||
const useGrouping = computed(() => {
|
||||
return props.widget.options?.useGrouping === true
|
||||
})
|
||||
|
||||
// Check if increment/decrement buttons should be disabled due to precision limits
|
||||
const buttonsDisabled = computed(() => {
|
||||
const currentValue = localValue.value || 0
|
||||
return !Number.isSafeInteger(currentValue)
|
||||
})
|
||||
|
||||
// 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)'
|
||||
}
|
||||
return null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WidgetLayoutField :widget>
|
||||
<div v-tooltip="buttonTooltip">
|
||||
<InputNumber
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
:show-buttons="!buttonsDisabled"
|
||||
button-layout="horizontal"
|
||||
size="small"
|
||||
:disabled="readonly"
|
||||
:step="stepValue"
|
||||
:use-grouping="useGrouping"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
|
||||
:pt="{
|
||||
incrementButton:
|
||||
'!rounded-r-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40',
|
||||
decrementButton:
|
||||
'!rounded-l-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40'
|
||||
}"
|
||||
@update:model-value="onChange"
|
||||
>
|
||||
<template #incrementicon>
|
||||
<span class="pi pi-plus text-sm" />
|
||||
</template>
|
||||
<template #decrementicon>
|
||||
<span class="pi pi-minus text-sm" />
|
||||
</template>
|
||||
</InputNumber>
|
||||
</div>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-inputnumber-input) {
|
||||
background-color: transparent;
|
||||
border: 1px solid color-mix(in oklab, #d4d4d8 10%, transparent);
|
||||
border-top: transparent;
|
||||
border-bottom: transparent;
|
||||
height: 1.625rem;
|
||||
margin: 1px 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
|
||||
|
||||
function createMockWidget(
|
||||
value: number = 5,
|
||||
options: SimplifiedWidget['options'] = {},
|
||||
callback?: (value: number) => void
|
||||
): SimplifiedWidget<number> {
|
||||
return {
|
||||
name: 'test_slider',
|
||||
type: 'float',
|
||||
value,
|
||||
options: { min: 0, max: 100, step: 1, precision: 0, ...options },
|
||||
callback
|
||||
}
|
||||
}
|
||||
|
||||
function mountComponent(
|
||||
widget: SimplifiedWidget<number>,
|
||||
modelValue: number,
|
||||
readonly = false
|
||||
) {
|
||||
return mount(WidgetInputNumberSlider, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { InputNumber, Slider }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getNumberInput(wrapper: ReturnType<typeof mount>) {
|
||||
const input = wrapper.find('input[inputmode="numeric"]')
|
||||
if (!(input.element instanceof HTMLInputElement)) {
|
||||
throw new Error(
|
||||
'Number input element not found or is not an HTMLInputElement'
|
||||
)
|
||||
}
|
||||
return input.element
|
||||
}
|
||||
|
||||
describe('WidgetInputNumberSlider Value Binding', () => {
|
||||
describe('Props and Values', () => {
|
||||
it('passes modelValue to slider component', () => {
|
||||
const widget = createMockWidget(5)
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const slider = wrapper.findComponent({ name: 'Slider' })
|
||||
expect(slider.props('modelValue')).toEqual([5])
|
||||
})
|
||||
|
||||
it('handles different initial values', () => {
|
||||
const widget1 = createMockWidget(5)
|
||||
const wrapper1 = mountComponent(widget1, 5)
|
||||
|
||||
const widget2 = createMockWidget(10)
|
||||
const wrapper2 = mountComponent(widget2, 10)
|
||||
|
||||
const slider1 = wrapper1.findComponent({ name: 'Slider' })
|
||||
expect(slider1.props('modelValue')).toEqual([5])
|
||||
|
||||
const slider2 = wrapper2.findComponent({ name: 'Slider' })
|
||||
expect(slider2.props('modelValue')).toEqual([10])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('renders slider component', () => {
|
||||
const widget = createMockWidget(5)
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
expect(wrapper.findComponent({ name: 'Slider' }).exists()).toBe(true)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it('displays initial value in input field', () => {
|
||||
const widget = createMockWidget(42)
|
||||
const wrapper = mountComponent(widget, 42)
|
||||
|
||||
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', () => {
|
||||
it('passes widget options to PrimeVue components', () => {
|
||||
const widget = createMockWidget(5, { min: -10, max: 50 })
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const slider = wrapper.findComponent({ name: 'Slider' })
|
||||
expect(slider.props('min')).toBe(-10)
|
||||
expect(slider.props('max')).toBe(50)
|
||||
})
|
||||
|
||||
it('handles negative value ranges', () => {
|
||||
const widget = createMockWidget(0, { min: -100, max: 100 })
|
||||
const wrapper = mountComponent(widget, 0)
|
||||
|
||||
const slider = wrapper.findComponent({ name: 'Slider' })
|
||||
expect(slider.props('min')).toBe(-100)
|
||||
expect(slider.props('max')).toBe(100)
|
||||
})
|
||||
|
||||
describe('Step Size', () => {
|
||||
it('should default to 1', () => {
|
||||
const widget = createMockWidget(5)
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const slider = wrapper.findComponent({ name: 'Slider' })
|
||||
expect(slider.props('step')).toBe(1)
|
||||
})
|
||||
|
||||
it('should get the step2 value if present', () => {
|
||||
const widget = createMockWidget(5, { step2: 0.01 })
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const slider = wrapper.findComponent({ name: 'Slider' })
|
||||
expect(slider.props('step')).toBe(0.01)
|
||||
})
|
||||
|
||||
it('should be 1 for precision 0', () => {
|
||||
const widget = createMockWidget(5, { precision: 0 })
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const slider = wrapper.findComponent({ name: 'Slider' })
|
||||
expect(slider.props('step')).toBe(1)
|
||||
})
|
||||
|
||||
it('should be .1 for precision 1', () => {
|
||||
const widget = createMockWidget(5, { precision: 1 })
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const slider = wrapper.findComponent({ name: 'Slider' })
|
||||
expect(slider.props('step')).toBe(0.1)
|
||||
})
|
||||
|
||||
it('should be .00001 for precision 5', () => {
|
||||
const widget = createMockWidget(5, { precision: 5 })
|
||||
const wrapper = mountComponent(widget, 5)
|
||||
|
||||
const slider = wrapper.findComponent({ name: 'Slider' })
|
||||
expect(slider.props('step')).toBe(0.00001)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget="widget">
|
||||
<div
|
||||
:class="
|
||||
cn(WidgetInputBaseClass, 'flex items-center gap-2 w-full pl-4 pr-2')
|
||||
"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[localValue]"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
class="flex-grow text-xs"
|
||||
:step="stepValue"
|
||||
@update:model-value="updateLocalValue"
|
||||
/>
|
||||
<InputNumber
|
||||
:key="timesEmptied"
|
||||
:model-value="localValue"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
:step="stepValue"
|
||||
:min-fraction-digits="precision"
|
||||
:max-fraction-digits="precision"
|
||||
size="small"
|
||||
pt:pc-input-text:root="min-w-full bg-transparent border-none text-center"
|
||||
class="w-16"
|
||||
@update:model-value="handleNumberInputUpdate"
|
||||
/>
|
||||
</div>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import { useNumberWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
STANDARD_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const { widget, modelValue, readonly } = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
modelValue: number
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useNumberWidgetValue(widget, modelValue, emit)
|
||||
|
||||
const timesEmptied = ref(0)
|
||||
|
||||
const updateLocalValue = (newValue: number[] | undefined): void => {
|
||||
onChange(newValue ?? [localValue.value])
|
||||
}
|
||||
|
||||
const handleNumberInputUpdate = (newValue: number | undefined) => {
|
||||
if (newValue) {
|
||||
updateLocalValue([newValue])
|
||||
return
|
||||
}
|
||||
timesEmptied.value += 1
|
||||
}
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
// Get the precision value for proper number formatting
|
||||
const precision = computed(() => {
|
||||
const p = widget.options?.precision
|
||||
// Treat negative or non-numeric precision as undefined
|
||||
return typeof p === 'number' && p >= 0 ? p : undefined
|
||||
})
|
||||
|
||||
// Calculate the step value based on precision or widget options
|
||||
const stepValue = computed(() => {
|
||||
// Use step2 (correct input spec value) instead of step (legacy 10x value)
|
||||
if (widget.options?.step2 !== undefined) {
|
||||
return widget.options.step2
|
||||
}
|
||||
|
||||
// Otherwise, derive from precision
|
||||
if (precision.value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (precision.value === 0) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// For precision > 0, step = 1 / (10^precision)
|
||||
// precision 1 → 0.1, precision 2 → 0.01, etc.
|
||||
return 1 / Math.pow(10, precision.value)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,193 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import type { InputTextProps } from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetInputText from './WidgetInputText.vue'
|
||||
|
||||
describe('WidgetInputText Value Binding', () => {
|
||||
const createMockWidget = (
|
||||
value: string = 'default',
|
||||
options: Partial<InputTextProps> = {},
|
||||
callback?: (value: string) => void
|
||||
): SimplifiedWidget<string> => ({
|
||||
name: 'test_input',
|
||||
type: 'string',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string>,
|
||||
modelValue: string,
|
||||
readonly = false
|
||||
) => {
|
||||
return mount(WidgetInputText, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { InputText, Textarea }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setInputValueAndTrigger = async (
|
||||
wrapper: ReturnType<typeof mount>,
|
||||
value: string,
|
||||
trigger: 'blur' | 'keydown.enter' = 'blur'
|
||||
) => {
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
if (!(input.element instanceof HTMLInputElement)) {
|
||||
throw new Error('Input element not found or is not an HTMLInputElement')
|
||||
}
|
||||
await input.setValue(value)
|
||||
await input.trigger(trigger)
|
||||
return input
|
||||
}
|
||||
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when input value changes on blur', async () => {
|
||||
const widget = createMockWidget('hello')
|
||||
const wrapper = mountComponent(widget, 'hello')
|
||||
|
||||
await setInputValueAndTrigger(wrapper, 'world', 'blur')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('world')
|
||||
})
|
||||
|
||||
it('emits Vue event when enter key is pressed', async () => {
|
||||
const widget = createMockWidget('initial')
|
||||
const wrapper = mountComponent(widget, 'initial')
|
||||
|
||||
await setInputValueAndTrigger(wrapper, 'new value', 'keydown.enter')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('new value')
|
||||
})
|
||||
|
||||
it('handles empty string values', async () => {
|
||||
const widget = createMockWidget('something')
|
||||
const wrapper = mountComponent(widget, 'something')
|
||||
|
||||
await setInputValueAndTrigger(wrapper, '')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('')
|
||||
})
|
||||
|
||||
it('handles special characters correctly', async () => {
|
||||
const widget = createMockWidget('normal')
|
||||
const wrapper = mountComponent(widget, 'normal')
|
||||
|
||||
const specialText = 'special @#$%^&*()[]{}|\\:";\'<>?,./'
|
||||
await setInputValueAndTrigger(wrapper, specialText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(specialText)
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget('test', {}, undefined)
|
||||
const wrapper = mountComponent(widget, 'test')
|
||||
|
||||
await setInputValueAndTrigger(wrapper, 'new value')
|
||||
|
||||
// Should still emit Vue event
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('new value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('emits update:modelValue on blur', async () => {
|
||||
const widget = createMockWidget('original')
|
||||
const wrapper = mountComponent(widget, 'original')
|
||||
|
||||
await setInputValueAndTrigger(wrapper, 'updated')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('updated')
|
||||
})
|
||||
|
||||
it('emits update:modelValue on enter key', async () => {
|
||||
const widget = createMockWidget('start')
|
||||
const wrapper = mountComponent(widget, 'start')
|
||||
|
||||
await setInputValueAndTrigger(wrapper, 'finish', 'keydown.enter')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('finish')
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
const wrapper = mountComponent(widget, 'test value')
|
||||
|
||||
// WidgetInputText always uses InputText, not Textarea
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
expect(input.exists()).toBe(true)
|
||||
|
||||
// Should not render textarea (that's handled by WidgetTextarea component)
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles very long strings', async () => {
|
||||
const widget = createMockWidget('short')
|
||||
const wrapper = mountComponent(widget, 'short')
|
||||
|
||||
const longString = 'a'.repeat(10000)
|
||||
await setInputValueAndTrigger(wrapper, longString)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(longString)
|
||||
})
|
||||
|
||||
it('handles unicode characters', async () => {
|
||||
const widget = createMockWidget('ascii')
|
||||
const wrapper = mountComponent(widget, 'ascii')
|
||||
|
||||
const unicodeText = '🎨 Unicode: αβγ 中文 العربية 🚀'
|
||||
await setInputValueAndTrigger(wrapper, unicodeText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(unicodeText)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget="widget">
|
||||
<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"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
INPUT_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
modelValue: string
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useStringWidgetValue(
|
||||
props.widget,
|
||||
props.modelValue,
|
||||
emit
|
||||
)
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,432 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetMarkdown from './WidgetMarkdown.vue'
|
||||
|
||||
// Mock the markdown renderer utility
|
||||
vi.mock('@/utils/markdownRendererUtil', () => ({
|
||||
renderMarkdownToHtml: vi.fn((markdown: string) => {
|
||||
// Simple mock that converts some markdown to HTML
|
||||
return markdown
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/^# (.*?)$/gm, '<h1>$1</h1>')
|
||||
.replace(/^## (.*?)$/gm, '<h2>$1</h2>')
|
||||
.replace(/\n/g, '<br>')
|
||||
})
|
||||
}))
|
||||
|
||||
describe('WidgetMarkdown Dual Mode Display', () => {
|
||||
const createMockWidget = (
|
||||
value: string = '# Default Heading\nSome **bold** text.',
|
||||
options: Record<string, unknown> = {},
|
||||
callback?: (value: string) => void
|
||||
): SimplifiedWidget<string> => ({
|
||||
name: 'test_markdown',
|
||||
type: 'string',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string>,
|
||||
modelValue: string,
|
||||
readonly = false
|
||||
) => {
|
||||
return mount(WidgetMarkdown, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { Textarea }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const clickToEdit = async (wrapper: ReturnType<typeof mount>) => {
|
||||
const container = wrapper.find('.widget-markdown')
|
||||
await container.trigger('click')
|
||||
await nextTick()
|
||||
return container
|
||||
}
|
||||
|
||||
const blurTextarea = async (wrapper: ReturnType<typeof mount>) => {
|
||||
const textarea = wrapper.find('textarea')
|
||||
if (textarea.exists()) {
|
||||
await textarea.trigger('blur')
|
||||
await nextTick()
|
||||
}
|
||||
return textarea
|
||||
}
|
||||
|
||||
describe('Display Mode', () => {
|
||||
it('renders markdown content as HTML in display mode', () => {
|
||||
const markdown = '# Heading\nSome **bold** and *italic* text.'
|
||||
const widget = createMockWidget(markdown)
|
||||
const wrapper = mountComponent(widget, markdown)
|
||||
|
||||
const displayDiv = wrapper.find('.comfy-markdown-content')
|
||||
expect(displayDiv.exists()).toBe(true)
|
||||
expect(displayDiv.html()).toContain('<h1>Heading</h1>')
|
||||
expect(displayDiv.html()).toContain('<strong>bold</strong>')
|
||||
expect(displayDiv.html()).toContain('<em>italic</em>')
|
||||
})
|
||||
|
||||
it('starts in display mode by default', () => {
|
||||
const widget = createMockWidget('# Test')
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
|
||||
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true)
|
||||
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, '')
|
||||
|
||||
const displayDiv = wrapper.find('.comfy-markdown-content')
|
||||
expect(displayDiv.exists()).toBe(true)
|
||||
expect(displayDiv.text()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Mode Toggle', () => {
|
||||
it('switches to edit mode when clicked', async () => {
|
||||
const widget = createMockWidget('# Test')
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
|
||||
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true)
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
|
||||
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(false)
|
||||
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')
|
||||
|
||||
// First click to enter edit mode
|
||||
await clickToEdit(wrapper)
|
||||
expect(wrapper.find('textarea').exists()).toBe(true)
|
||||
|
||||
// Second click should not have any effect
|
||||
await clickToEdit(wrapper)
|
||||
expect(wrapper.find('textarea').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('switches back to display mode on textarea blur', async () => {
|
||||
const widget = createMockWidget('# Test')
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
expect(wrapper.find('textarea').exists()).toBe(true)
|
||||
|
||||
await blurTextarea(wrapper)
|
||||
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true)
|
||||
expect(wrapper.find('textarea').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
it('displays textarea with current value when editing', async () => {
|
||||
const markdown = '# Original Content'
|
||||
const widget = createMockWidget(markdown)
|
||||
const wrapper = mountComponent(widget, markdown)
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.exists()).toBe(true)
|
||||
expect(textarea.element.value).toBe('# Original Content')
|
||||
})
|
||||
|
||||
it('applies styling and configuration to textarea', async () => {
|
||||
const widget = createMockWidget('# Test')
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
|
||||
const textarea = wrapper.findComponent({ name: 'Textarea' })
|
||||
expect(textarea.props('size')).toBe('small')
|
||||
// Check rows attribute in the DOM instead of props
|
||||
const textareaElement = wrapper.find('textarea')
|
||||
expect(textareaElement.attributes('rows')).toBe('6')
|
||||
expect(textarea.classes()).toContain('text-xs')
|
||||
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')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
const clickSpy = vi.fn()
|
||||
const keydownSpy = vi.fn()
|
||||
|
||||
wrapper.element.addEventListener('click', clickSpy)
|
||||
wrapper.element.addEventListener('keydown', keydownSpy)
|
||||
|
||||
await textarea.trigger('click')
|
||||
await textarea.trigger('keydown', { key: 'Enter' })
|
||||
|
||||
// Events should be stopped from propagating
|
||||
expect(clickSpy).not.toHaveBeenCalled()
|
||||
expect(keydownSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Value Updates', () => {
|
||||
it('emits update:modelValue when textarea content changes', async () => {
|
||||
const widget = createMockWidget('# Original')
|
||||
const wrapper = mountComponent(widget, '# Original')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('# Updated Content')
|
||||
await textarea.trigger('input')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![emitted!.length - 1]).toEqual(['# Updated Content'])
|
||||
})
|
||||
|
||||
it('renders updated HTML after value change and blur', async () => {
|
||||
const widget = createMockWidget('# Original')
|
||||
const wrapper = mountComponent(widget, '# Original')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('## New Heading\nWith **bold** text')
|
||||
await textarea.trigger('input')
|
||||
await blurTextarea(wrapper)
|
||||
|
||||
const displayDiv = wrapper.find('.comfy-markdown-content')
|
||||
expect(displayDiv.html()).toContain('<h2>New Heading</h2>')
|
||||
expect(displayDiv.html()).toContain('<strong>bold</strong>')
|
||||
})
|
||||
|
||||
it('emits update:modelValue for callback handling at parent level', async () => {
|
||||
const widget = createMockWidget('# Test', {})
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('# Changed')
|
||||
await textarea.trigger('input')
|
||||
|
||||
// The widget should emit the change for parent (NodeWidgets) to handle callbacks
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![emitted!.length - 1]).toEqual(['# Changed'])
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget('# Test', {}, undefined)
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('# Changed')
|
||||
|
||||
// Should not throw error and should still emit Vue event
|
||||
await expect(textarea.trigger('input')).resolves.not.toThrow()
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Complex Markdown Rendering', () => {
|
||||
it('handles multiple markdown elements', () => {
|
||||
const complexMarkdown = `# Main Heading
|
||||
## Subheading
|
||||
This paragraph has **bold** and *italic* text.
|
||||
Another line with more content.`
|
||||
|
||||
const widget = createMockWidget(complexMarkdown)
|
||||
const wrapper = mountComponent(widget, complexMarkdown)
|
||||
|
||||
const displayDiv = wrapper.find('.comfy-markdown-content')
|
||||
expect(displayDiv.html()).toContain('<h1>Main Heading</h1>')
|
||||
expect(displayDiv.html()).toContain('<h2>Subheading</h2>')
|
||||
expect(displayDiv.html()).toContain('<strong>bold</strong>')
|
||||
expect(displayDiv.html()).toContain('<em>italic</em>')
|
||||
})
|
||||
|
||||
it('handles line breaks in markdown', () => {
|
||||
const markdownWithBreaks = 'Line 1\nLine 2\nLine 3'
|
||||
const widget = createMockWidget(markdownWithBreaks)
|
||||
const wrapper = mountComponent(widget, markdownWithBreaks)
|
||||
|
||||
const displayDiv = wrapper.find('.comfy-markdown-content')
|
||||
expect(displayDiv.html()).toContain('<br>')
|
||||
})
|
||||
|
||||
it('handles empty or whitespace-only markdown', () => {
|
||||
const whitespaceMarkdown = ' \n\n '
|
||||
const widget = createMockWidget(whitespaceMarkdown)
|
||||
const wrapper = mountComponent(widget, whitespaceMarkdown)
|
||||
|
||||
const displayDiv = wrapper.find('.comfy-markdown-content')
|
||||
expect(displayDiv.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles very long markdown content', async () => {
|
||||
const longMarkdown = '# Heading\n' + 'Lorem ipsum '.repeat(1000)
|
||||
const widget = createMockWidget(longMarkdown)
|
||||
const wrapper = mountComponent(widget, longMarkdown)
|
||||
|
||||
// Should render without issues
|
||||
const displayDiv = wrapper.find('.comfy-markdown-content')
|
||||
expect(displayDiv.exists()).toBe(true)
|
||||
|
||||
// Should switch to edit mode
|
||||
await clickToEdit(wrapper)
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.exists()).toBe(true)
|
||||
expect(textarea.element.value).toBe(longMarkdown)
|
||||
})
|
||||
|
||||
it('handles special characters in markdown', async () => {
|
||||
const specialChars = '# Special: @#$%^&*()[]{}|\\:";\'<>?,./'
|
||||
const widget = createMockWidget(specialChars)
|
||||
const wrapper = mountComponent(widget, specialChars)
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.element.value).toBe(specialChars)
|
||||
})
|
||||
|
||||
it('handles unicode characters', async () => {
|
||||
const unicode = '# Unicode: 🎨 αβγ 中文 العربية 🚀'
|
||||
const widget = createMockWidget(unicode)
|
||||
const wrapper = mountComponent(widget, unicode)
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.element.value).toBe(unicode)
|
||||
|
||||
await textarea.setValue(unicode + ' more unicode')
|
||||
await textarea.trigger('input')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![emitted!.length - 1]).toEqual([unicode + ' more unicode'])
|
||||
})
|
||||
|
||||
it('handles rapid edit mode toggling', async () => {
|
||||
const widget = createMockWidget('# Test')
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
|
||||
// Rapid toggling
|
||||
await clickToEdit(wrapper)
|
||||
expect(wrapper.find('textarea').exists()).toBe(true)
|
||||
|
||||
await blurTextarea(wrapper)
|
||||
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true)
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
expect(wrapper.find('textarea').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling and Layout', () => {
|
||||
it('applies widget-markdown class to container', () => {
|
||||
const widget = createMockWidget('# Test')
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
|
||||
const container = wrapper.find('.widget-markdown')
|
||||
expect(container.exists()).toBe(true)
|
||||
expect(container.classes()).toContain('relative')
|
||||
expect(container.classes()).toContain('w-full')
|
||||
expect(container.classes()).toContain('cursor-text')
|
||||
})
|
||||
|
||||
it('applies overflow handling to display mode', () => {
|
||||
const widget = createMockWidget(
|
||||
'# Long Content\n' + 'Content '.repeat(100)
|
||||
)
|
||||
const wrapper = mountComponent(
|
||||
widget,
|
||||
'# Long Content\n' + 'Content '.repeat(100)
|
||||
)
|
||||
|
||||
const displayDiv = wrapper.find('.comfy-markdown-content')
|
||||
expect(displayDiv.classes()).toContain('overflow-y-auto')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Focus Management', () => {
|
||||
it('creates textarea reference when entering edit mode', async () => {
|
||||
const widget = createMockWidget('# Test')
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
const vm = wrapper.vm as InstanceType<typeof WidgetMarkdown>
|
||||
|
||||
// Test that the component creates a textarea reference when entering edit mode
|
||||
// @ts-expect-error - isEditing is not exposed
|
||||
expect(vm.isEditing).toBe(false)
|
||||
|
||||
// @ts-expect-error - startEditing is not exposed
|
||||
await vm.startEditing()
|
||||
|
||||
// @ts-expect-error - isEditing is not exposed
|
||||
expect(vm.isEditing).toBe(true)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Check that textarea exists after entering edit mode
|
||||
const textarea = wrapper.findComponent({ name: 'Textarea' })
|
||||
expect(textarea.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div
|
||||
class="widget-markdown relative w-full cursor-text"
|
||||
@click="startEditing"
|
||||
>
|
||||
<!-- Display mode: Rendered markdown -->
|
||||
<div
|
||||
class="comfy-markdown-content hover:bg-[var(--p-content-hover-background)] text-sm min-h-[60px] w-full rounded-lg px-4 py-2 overflow-y-auto lod-toggle"
|
||||
:class="isEditing === false ? 'visible' : 'invisible'"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
|
||||
<!-- Edit mode: Textarea -->
|
||||
<Textarea
|
||||
v-show="isEditing"
|
||||
ref="textareaRef"
|
||||
v-model="localValue"
|
||||
:disabled="readonly"
|
||||
class="w-full min-h-[60px] absolute inset-0 resize-none"
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'text-sm w-full h-full',
|
||||
onBlur: handleBlur
|
||||
}
|
||||
}"
|
||||
@update:model-value="onChange"
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
/>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
import LODFallback from '../../components/LODFallback.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
modelValue: string
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
// State
|
||||
const isEditing = ref(false)
|
||||
const textareaRef = ref<InstanceType<typeof Textarea> | undefined>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useStringWidgetValue(
|
||||
props.widget,
|
||||
props.modelValue,
|
||||
emit
|
||||
)
|
||||
|
||||
// Computed
|
||||
const renderedHtml = computed(() => {
|
||||
return renderMarkdownToHtml(localValue.value || '')
|
||||
})
|
||||
|
||||
// Methods
|
||||
const startEditing = async () => {
|
||||
if (props.readonly || isEditing.value) return
|
||||
|
||||
isEditing.value = true
|
||||
await nextTick()
|
||||
|
||||
// Focus the textarea
|
||||
// @ts-expect-error - $el is an internal property of the Textarea component
|
||||
textareaRef.value?.$el?.focus()
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
isEditing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.widget-markdown {
|
||||
background-color: var(--p-muted-color);
|
||||
border: 1px solid var(--p-border-color);
|
||||
border-radius: var(--p-border-radius);
|
||||
}
|
||||
|
||||
.comfy-markdown-content:hover {
|
||||
background-color: var(--p-content-hover-background);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,360 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
import type { MultiSelectProps } from 'primevue/multiselect'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetMultiSelect from './WidgetMultiSelect.vue'
|
||||
|
||||
describe('WidgetMultiSelect Value Binding', () => {
|
||||
const createMockWidget = (
|
||||
value: WidgetValue[] = [],
|
||||
options: Partial<MultiSelectProps> & { values?: WidgetValue[] } = {},
|
||||
callback?: (value: WidgetValue[]) => void
|
||||
): SimplifiedWidget<WidgetValue[]> => ({
|
||||
name: 'test_multiselect',
|
||||
type: 'array',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<WidgetValue[]>,
|
||||
modelValue: WidgetValue[],
|
||||
readonly = false
|
||||
) => {
|
||||
return mount(WidgetMultiSelect, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { MultiSelect }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setMultiSelectValueAndEmit = async (
|
||||
wrapper: ReturnType<typeof mount>,
|
||||
values: WidgetValue[]
|
||||
) => {
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
await multiselect.vm.$emit('update:modelValue', values)
|
||||
return multiselect
|
||||
}
|
||||
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when selection changes', async () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: ['option1', 'option2', 'option3']
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
await setMultiSelectValueAndEmit(wrapper, ['option1', 'option2'])
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([['option1', 'option2']])
|
||||
})
|
||||
|
||||
it('emits Vue event when selection is cleared', async () => {
|
||||
const widget = createMockWidget(['option1'], {
|
||||
values: ['option1', 'option2']
|
||||
})
|
||||
const wrapper = mountComponent(widget, ['option1'])
|
||||
|
||||
await setMultiSelectValueAndEmit(wrapper, [])
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([[]])
|
||||
})
|
||||
|
||||
it('handles single item selection', async () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: ['single']
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
await setMultiSelectValueAndEmit(wrapper, ['single'])
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([['single']])
|
||||
})
|
||||
|
||||
it('emits update:modelValue for callback handling at parent level', async () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: ['option1', 'option2']
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
await setMultiSelectValueAndEmit(wrapper, ['option1'])
|
||||
|
||||
// The widget should emit the change for parent (NodeWidgets) to handle callbacks
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([['option1']])
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget(
|
||||
[],
|
||||
{
|
||||
values: ['option1']
|
||||
},
|
||||
undefined
|
||||
)
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
await setMultiSelectValueAndEmit(wrapper, ['option1'])
|
||||
|
||||
// Should still emit Vue event
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([['option1']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('renders multiselect component', () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: ['option1', 'option2']
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
expect(multiselect.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays options from widget values', () => {
|
||||
const options = ['apple', 'banana', 'cherry']
|
||||
const widget = createMockWidget([], { values: options })
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
expect(multiselect.props('options')).toEqual(options)
|
||||
})
|
||||
|
||||
it('displays initial selected values', () => {
|
||||
const widget = createMockWidget(['banana'], {
|
||||
values: ['apple', 'banana', 'cherry']
|
||||
})
|
||||
const wrapper = mountComponent(widget, ['banana'])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
expect(multiselect.props('modelValue')).toEqual(['banana'])
|
||||
})
|
||||
|
||||
it('applies small size styling', () => {
|
||||
const widget = createMockWidget([], { values: ['test'] })
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
expect(multiselect.props('size')).toBe('small')
|
||||
})
|
||||
|
||||
it('uses chip display mode', () => {
|
||||
const widget = createMockWidget([], { values: ['test'] })
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
expect(multiselect.props('display')).toBe('chip')
|
||||
})
|
||||
|
||||
it('applies text-xs class', () => {
|
||||
const widget = createMockWidget([], { values: ['test'] })
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
expect(multiselect.classes()).toContain('text-xs')
|
||||
})
|
||||
})
|
||||
|
||||
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([], {
|
||||
values: ['option1', 'option2'],
|
||||
placeholder: 'Select items...',
|
||||
filter: true,
|
||||
showClear: true
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
expect(multiselect.props('placeholder')).toBe('Select items...')
|
||||
expect(multiselect.props('filter')).toBe(true)
|
||||
expect(multiselect.props('showClear')).toBe(true)
|
||||
})
|
||||
|
||||
it('excludes panel-related props', () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: ['option1'],
|
||||
overlayStyle: { color: 'red' },
|
||||
panelClass: 'custom-panel'
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
// These props should be filtered out by the prop filter
|
||||
expect(multiselect.props('overlayStyle')).not.toEqual({ color: 'red' })
|
||||
expect(multiselect.props('panelClass')).not.toBe('custom-panel')
|
||||
})
|
||||
|
||||
it('handles empty values array', () => {
|
||||
const widget = createMockWidget([], { values: [] })
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
expect(multiselect.props('options')).toEqual([])
|
||||
})
|
||||
|
||||
it('handles missing values option', () => {
|
||||
const widget = createMockWidget([])
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
// Should not crash, options might be undefined
|
||||
expect(multiselect.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles numeric values', async () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: [1, 2, 3, 4, 5]
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
await setMultiSelectValueAndEmit(wrapper, [1, 3, 5])
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([[1, 3, 5]])
|
||||
})
|
||||
|
||||
it('handles mixed type values', async () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: ['string', 123, true, null]
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
await setMultiSelectValueAndEmit(wrapper, ['string', 123])
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([['string', 123]])
|
||||
})
|
||||
|
||||
it('handles object values', async () => {
|
||||
const objectValues = [
|
||||
{ id: 1, label: 'First' },
|
||||
{ id: 2, label: 'Second' }
|
||||
]
|
||||
const widget = createMockWidget([], {
|
||||
values: objectValues,
|
||||
optionLabel: 'label',
|
||||
optionValue: 'id'
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
await setMultiSelectValueAndEmit(wrapper, [1, 2])
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([[1, 2]])
|
||||
})
|
||||
|
||||
it('handles duplicate selections gracefully', async () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: ['option1', 'option2']
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
// MultiSelect should handle duplicates internally
|
||||
await setMultiSelectValueAndEmit(wrapper, ['option1', 'option1'])
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
// The actual behavior depends on PrimeVue implementation
|
||||
expect(emitted![0]).toEqual([['option1', 'option1']])
|
||||
})
|
||||
|
||||
it('handles very large option lists', () => {
|
||||
const largeOptionList = Array.from(
|
||||
{ length: 1000 },
|
||||
(_, i) => `option${i}`
|
||||
)
|
||||
const widget = createMockWidget([], { values: largeOptionList })
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
expect(multiselect.props('options')).toHaveLength(1000)
|
||||
})
|
||||
|
||||
it('handles empty string values', async () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: ['', 'not empty', ' ', 'normal']
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
await setMultiSelectValueAndEmit(wrapper, ['', ' '])
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([['', ' ']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration with Layout', () => {
|
||||
it('renders within WidgetLayoutField', () => {
|
||||
const widget = createMockWidget([], { values: ['test'] })
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
expect(layoutField.exists()).toBe(true)
|
||||
expect(layoutField.props('widget')).toEqual(widget)
|
||||
})
|
||||
|
||||
it('passes widget name to layout field', () => {
|
||||
const widget = createMockWidget([], { values: ['test'] })
|
||||
widget.name = 'custom_multiselect'
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
expect(layoutField.props('widget').name).toBe('custom_multiselect')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget="widget">
|
||||
<MultiSelect
|
||||
v-model="localValue"
|
||||
:options="multiSelectOptions"
|
||||
v-bind="combinedProps"
|
||||
:disabled="readonly"
|
||||
class="w-full text-xs"
|
||||
size="small"
|
||||
display="chip"
|
||||
:pt="{
|
||||
option: 'text-xs'
|
||||
}"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends WidgetValue = WidgetValue">
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<T[]>
|
||||
modelValue: T[]
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: T[]]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useWidgetValue<T[]>({
|
||||
widget: props.widget,
|
||||
modelValue: props.modelValue,
|
||||
defaultValue: [],
|
||||
emit
|
||||
})
|
||||
|
||||
// Transform compatibility props for overlay positioning
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
// MultiSelect specific excluded props include overlay styles
|
||||
const MULTISELECT_EXCLUDED_PROPS = [
|
||||
...PANEL_EXCLUDED_PROPS,
|
||||
'overlayStyle'
|
||||
] as const
|
||||
|
||||
const combinedProps = computed(() => ({
|
||||
...filterWidgetProps(props.widget.options, MULTISELECT_EXCLUDED_PROPS),
|
||||
...transformCompatProps.value
|
||||
}))
|
||||
|
||||
// Extract multiselect options from widget options
|
||||
const multiSelectOptions = computed((): T[] => {
|
||||
const options = props.widget.options
|
||||
|
||||
if (Array.isArray(options?.values)) {
|
||||
return options.values
|
||||
}
|
||||
|
||||
return []
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,232 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Select from 'primevue/select'
|
||||
import type { SelectProps } from 'primevue/select'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetSelect from './WidgetSelect.vue'
|
||||
import WidgetSelectDefault from './WidgetSelectDefault.vue'
|
||||
import WidgetSelectDropdown from './WidgetSelectDropdown.vue'
|
||||
|
||||
describe('WidgetSelect Value Binding', () => {
|
||||
const createMockWidget = (
|
||||
value: string = 'option1',
|
||||
options: Partial<
|
||||
SelectProps & { values?: string[]; return_index?: boolean }
|
||||
> = {},
|
||||
callback?: (value: string | number | undefined) => void,
|
||||
spec?: ComboInputSpec
|
||||
): SimplifiedWidget<string | number | undefined> => ({
|
||||
name: 'test_select',
|
||||
type: 'combo',
|
||||
value,
|
||||
options: {
|
||||
values: ['option1', 'option2', 'option3'],
|
||||
...options
|
||||
},
|
||||
callback,
|
||||
spec
|
||||
})
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string | number | undefined>,
|
||||
modelValue: string | number | undefined,
|
||||
readonly = false
|
||||
) => {
|
||||
return mount(WidgetSelect, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { Select }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setSelectValueAndEmit = async (
|
||||
wrapper: ReturnType<typeof mount>,
|
||||
value: string
|
||||
) => {
|
||||
const select = wrapper.findComponent({ name: 'Select' })
|
||||
await select.setValue(value)
|
||||
return wrapper.emitted('update:modelValue')
|
||||
}
|
||||
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when selection changes', async () => {
|
||||
const widget = createMockWidget('option1')
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, 'option2')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('option2')
|
||||
})
|
||||
|
||||
it('emits string value for different options', async () => {
|
||||
const widget = createMockWidget('option1')
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, 'option3')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
// Should emit the string value
|
||||
expect(emitted![0]).toContain('option3')
|
||||
})
|
||||
|
||||
it('handles custom option values', async () => {
|
||||
const customOptions = ['custom_a', 'custom_b', 'custom_c']
|
||||
const widget = createMockWidget('custom_a', { values: customOptions })
|
||||
const wrapper = mountComponent(widget, 'custom_a')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, 'custom_b')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('custom_b')
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget('option1', {}, undefined)
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, 'option2')
|
||||
|
||||
// Should emit Vue event
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('option2')
|
||||
})
|
||||
|
||||
it('handles value changes gracefully', async () => {
|
||||
const widget = createMockWidget('option1')
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, 'option2')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('option2')
|
||||
})
|
||||
})
|
||||
|
||||
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: [] })
|
||||
const wrapper = mountComponent(widget, '')
|
||||
|
||||
const select = wrapper.findComponent({ name: 'Select' })
|
||||
expect(select.props('options')).toEqual([])
|
||||
})
|
||||
|
||||
it('handles single option', async () => {
|
||||
const widget = createMockWidget('only_option', {
|
||||
values: ['only_option']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'only_option')
|
||||
|
||||
const select = wrapper.findComponent({ name: 'Select' })
|
||||
const options = select.props('options')
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0]).toEqual('only_option')
|
||||
})
|
||||
|
||||
it('handles options with special characters', async () => {
|
||||
const specialOptions = [
|
||||
'option with spaces',
|
||||
'option@#$%',
|
||||
'option/with\\slashes'
|
||||
]
|
||||
const widget = createMockWidget(specialOptions[0], {
|
||||
values: specialOptions
|
||||
})
|
||||
const wrapper = mountComponent(widget, specialOptions[0])
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, specialOptions[1])
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(specialOptions[1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles selection of non-existent option gracefully', async () => {
|
||||
const widget = createMockWidget('option1')
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(
|
||||
wrapper,
|
||||
'non_existent_option'
|
||||
)
|
||||
|
||||
// Should still emit Vue event with the value
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('non_existent_option')
|
||||
})
|
||||
|
||||
it('handles numeric string options correctly', async () => {
|
||||
const numericOptions = ['1', '2', '10', '100']
|
||||
const widget = createMockWidget('1', { values: numericOptions })
|
||||
const wrapper = mountComponent(widget, '1')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, '100')
|
||||
|
||||
// Should maintain string type in emitted event
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('100')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Spec-aware rendering', () => {
|
||||
it('uses dropdown variant when combo spec enables image uploads', () => {
|
||||
const spec: ComboInputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'test_select',
|
||||
image_upload: true
|
||||
}
|
||||
const widget = createMockWidget('option1', {}, undefined, spec)
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
expect(wrapper.findComponent(WidgetSelectDropdown).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(WidgetSelectDefault).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('uses dropdown variant for audio uploads', () => {
|
||||
const spec: ComboInputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'test_select',
|
||||
audio_upload: true
|
||||
}
|
||||
const widget = createMockWidget('clip.wav', {}, undefined, spec)
|
||||
const wrapper = mountComponent(widget, 'clip.wav')
|
||||
const dropdown = wrapper.findComponent(WidgetSelectDropdown)
|
||||
|
||||
expect(dropdown.exists()).toBe(true)
|
||||
expect(dropdown.props('assetKind')).toBe('audio')
|
||||
expect(dropdown.props('allowUpload')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps default select when no spec or media hints are present', () => {
|
||||
const widget = createMockWidget('plain', {
|
||||
values: ['plain', 'text']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'plain')
|
||||
|
||||
expect(wrapper.findComponent(WidgetSelectDefault).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(WidgetSelectDropdown).exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<WidgetSelectDropdown
|
||||
v-if="isDropdownUIWidget"
|
||||
v-bind="props"
|
||||
:asset-kind="assetKind"
|
||||
:allow-upload="allowUpload"
|
||||
:upload-folder="uploadFolder"
|
||||
@update:model-value="handleUpdateModelValue"
|
||||
/>
|
||||
<WidgetSelectDefault
|
||||
v-else
|
||||
v-bind="props"
|
||||
@update:model-value="handleUpdateModelValue"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import {
|
||||
type ComboInputSpec,
|
||||
isComboInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
|
||||
import WidgetSelectDefault from './WidgetSelectDefault.vue'
|
||||
import WidgetSelectDropdown from './WidgetSelectDropdown.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | number | undefined>
|
||||
modelValue: string | number | undefined
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number | undefined]
|
||||
}>()
|
||||
|
||||
function handleUpdateModelValue(value: string | number | undefined) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
const comboSpec = computed<ComboInputSpec | undefined>(() => {
|
||||
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {
|
||||
return props.widget.spec
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const specDescriptor = computed<{
|
||||
kind: AssetKind
|
||||
allowUpload: boolean
|
||||
folder: ResultItemType | undefined
|
||||
}>(() => {
|
||||
const spec = comboSpec.value
|
||||
if (!spec) {
|
||||
return {
|
||||
kind: 'unknown',
|
||||
allowUpload: false,
|
||||
folder: undefined
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
image_upload,
|
||||
animated_image_upload,
|
||||
video_upload,
|
||||
image_folder,
|
||||
audio_upload
|
||||
} = spec
|
||||
|
||||
let kind: AssetKind = 'unknown'
|
||||
if (video_upload) {
|
||||
kind = 'video'
|
||||
} else if (image_upload || animated_image_upload) {
|
||||
kind = 'image'
|
||||
} else if (audio_upload) {
|
||||
kind = 'audio'
|
||||
}
|
||||
// TODO: add support for models (checkpoints, VAE, LoRAs, etc.) -- get widgetType from spec
|
||||
|
||||
const allowUpload =
|
||||
image_upload === true ||
|
||||
animated_image_upload === true ||
|
||||
audio_upload === true
|
||||
return {
|
||||
kind,
|
||||
allowUpload,
|
||||
folder: image_folder
|
||||
}
|
||||
})
|
||||
|
||||
const assetKind = computed(() => specDescriptor.value.kind)
|
||||
const isDropdownUIWidget = computed(() => assetKind.value !== 'unknown')
|
||||
const allowUpload = computed(() => specDescriptor.value.allowUpload)
|
||||
const uploadFolder = computed<ResultItemType>(() => {
|
||||
return specDescriptor.value.folder ?? 'input'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,433 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetSelectButton from './WidgetSelectButton.vue'
|
||||
|
||||
function createMockWidget(
|
||||
value: string = 'option1',
|
||||
options: SimplifiedWidget['options'] = {},
|
||||
callback?: (value: string) => void
|
||||
): SimplifiedWidget<string> {
|
||||
return {
|
||||
name: 'test_selectbutton',
|
||||
type: 'string',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
}
|
||||
}
|
||||
|
||||
function mountComponent(
|
||||
widget: SimplifiedWidget<string>,
|
||||
modelValue: string,
|
||||
readonly = false
|
||||
) {
|
||||
return mount(WidgetSelectButton, {
|
||||
global: {
|
||||
plugins: [PrimeVue]
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function clickSelectButton(
|
||||
wrapper: ReturnType<typeof mount>,
|
||||
optionText: string
|
||||
) {
|
||||
const buttons = wrapper.findAll('button')
|
||||
const targetButton = buttons.find((button) =>
|
||||
button.text().includes(optionText)
|
||||
)
|
||||
|
||||
if (!targetButton) {
|
||||
throw new Error(`Button with text "${optionText}" not found`)
|
||||
}
|
||||
|
||||
await targetButton.trigger('click')
|
||||
return targetButton
|
||||
}
|
||||
|
||||
describe('WidgetSelectButton Button Selection', () => {
|
||||
describe('Basic Rendering', () => {
|
||||
it('renders FormSelectButton component', () => {
|
||||
const widget = createMockWidget('option1', {
|
||||
values: ['option1', 'option2', 'option3']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const formSelectButton = wrapper.findComponent({
|
||||
name: 'FormSelectButton'
|
||||
})
|
||||
expect(formSelectButton.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders buttons for each option', () => {
|
||||
const options = ['first', 'second', 'third']
|
||||
const widget = createMockWidget('first', { values: options })
|
||||
const wrapper = mountComponent(widget, 'first')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
expect(buttons[0].text()).toBe('first')
|
||||
expect(buttons[1].text()).toBe('second')
|
||||
expect(buttons[2].text()).toBe('third')
|
||||
})
|
||||
|
||||
it('handles empty options array', () => {
|
||||
const widget = createMockWidget('', { values: [] })
|
||||
const wrapper = mountComponent(widget, '')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles missing values option', () => {
|
||||
const widget = createMockWidget('')
|
||||
const wrapper = mountComponent(widget, '')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selection State', () => {
|
||||
it('highlights selected option', () => {
|
||||
const options = ['apple', 'banana', 'cherry']
|
||||
const widget = createMockWidget('banana', { values: options })
|
||||
const wrapper = mountComponent(widget, 'banana')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const selectedButton = buttons[1] // 'banana'
|
||||
const unselectedButton = buttons[0] // 'apple'
|
||||
|
||||
expect(selectedButton.classes()).toContain('bg-white')
|
||||
expect(selectedButton.classes()).toContain('text-neutral-900')
|
||||
expect(unselectedButton.classes()).not.toContain('bg-white')
|
||||
expect(unselectedButton.classes()).not.toContain('text-neutral-900')
|
||||
})
|
||||
|
||||
it('handles no selection gracefully', () => {
|
||||
const options = ['option1', 'option2']
|
||||
const widget = createMockWidget('nonexistent', { values: options })
|
||||
const wrapper = mountComponent(widget, 'nonexistent')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button.classes()).not.toContain('bg-white')
|
||||
expect(button.classes()).not.toContain('text-neutral-900')
|
||||
})
|
||||
})
|
||||
|
||||
it('updates selection when modelValue changes', async () => {
|
||||
const options = ['first', 'second', 'third']
|
||||
const widget = createMockWidget('first', { values: options })
|
||||
const wrapper = mountComponent(widget, 'first')
|
||||
|
||||
// Initially 'first' is selected
|
||||
let buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].classes()).toContain('bg-white')
|
||||
|
||||
// Update to 'second'
|
||||
await wrapper.setProps({ modelValue: 'second' })
|
||||
buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].classes()).not.toContain('bg-white')
|
||||
expect(buttons[1].classes()).toContain('bg-white')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('emits update:modelValue when button is clicked', async () => {
|
||||
const options = ['first', 'second', 'third']
|
||||
const widget = createMockWidget('first', { values: options })
|
||||
const wrapper = mountComponent(widget, 'first')
|
||||
|
||||
await clickSelectButton(wrapper, 'second')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toEqual(['second'])
|
||||
})
|
||||
|
||||
it('handles callback execution when provided', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const options = ['option1', 'option2']
|
||||
const widget = createMockWidget(
|
||||
'option1',
|
||||
{ values: options },
|
||||
mockCallback
|
||||
)
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
await clickSelectButton(wrapper, 'option2')
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledWith('option2')
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const options = ['option1', 'option2']
|
||||
const widget = createMockWidget('option1', { values: options }, undefined)
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
await clickSelectButton(wrapper, 'option2')
|
||||
|
||||
// Should still emit Vue event
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toEqual(['option2'])
|
||||
})
|
||||
|
||||
it('allows clicking same option again', async () => {
|
||||
const options = ['option1', 'option2']
|
||||
const widget = createMockWidget('option1', { values: options })
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
await clickSelectButton(wrapper, 'option1')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toEqual(['option1'])
|
||||
})
|
||||
})
|
||||
|
||||
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']
|
||||
const widget = createMockWidget('banana', { values: options })
|
||||
const wrapper = mountComponent(widget, 'banana')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toBe('apple')
|
||||
expect(buttons[1].text()).toBe('banana')
|
||||
expect(buttons[2].text()).toBe('cherry')
|
||||
})
|
||||
|
||||
it('handles number options', () => {
|
||||
const options = [1, 2, 3]
|
||||
const widget = createMockWidget('2', { values: options })
|
||||
const wrapper = mountComponent(widget, '2')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toBe('1')
|
||||
expect(buttons[1].text()).toBe('2')
|
||||
expect(buttons[2].text()).toBe('3')
|
||||
|
||||
// The selected button should be the one with '2'
|
||||
expect(buttons[1].classes()).toContain('bg-white')
|
||||
})
|
||||
|
||||
it('handles object options with label and value', () => {
|
||||
const options = [
|
||||
{ label: 'First Option', value: 'first' },
|
||||
{ label: 'Second Option', value: 'second' },
|
||||
{ label: 'Third Option', value: 'third' }
|
||||
]
|
||||
const widget = createMockWidget('second', { values: options })
|
||||
const wrapper = mountComponent(widget, 'second')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toBe('First Option')
|
||||
expect(buttons[1].text()).toBe('Second Option')
|
||||
expect(buttons[2].text()).toBe('Third Option')
|
||||
|
||||
// 'second' should be selected
|
||||
expect(buttons[1].classes()).toContain('bg-white')
|
||||
})
|
||||
|
||||
it('emits correct values for object options', async () => {
|
||||
const options = [
|
||||
{ label: 'First', value: 'first_val' },
|
||||
{ label: 'Second', value: 'second_val' }
|
||||
]
|
||||
const widget = createMockWidget('first_val', { values: options })
|
||||
const wrapper = mountComponent(widget, 'first_val')
|
||||
|
||||
await clickSelectButton(wrapper, 'Second')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toEqual(['second_val'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles options with special characters', () => {
|
||||
const options = ['@#$%^&*()', '{}[]|\\:";\'<>?,./']
|
||||
const widget = createMockWidget(options[0], { values: options })
|
||||
const wrapper = mountComponent(widget, options[0])
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toBe('@#$%^&*()')
|
||||
expect(buttons[1].text()).toBe('{}[]|\\:";\'<>?,./')
|
||||
})
|
||||
|
||||
it('handles empty string options', () => {
|
||||
const options = ['', 'not empty', ' ', 'normal']
|
||||
const widget = createMockWidget('', { values: options })
|
||||
const wrapper = mountComponent(widget, '')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(4)
|
||||
expect(buttons[0].classes()).toContain('bg-white') // Empty string is selected
|
||||
})
|
||||
|
||||
it('handles null/undefined in options', () => {
|
||||
const options: (string | null | undefined)[] = [
|
||||
'valid',
|
||||
null,
|
||||
undefined,
|
||||
'another'
|
||||
]
|
||||
const widget = createMockWidget('valid', { values: options })
|
||||
const wrapper = mountComponent(widget, 'valid')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(4)
|
||||
expect(buttons[0].classes()).toContain('bg-white')
|
||||
})
|
||||
|
||||
it('handles very long option text', () => {
|
||||
const longText =
|
||||
'This is a very long option text that might cause layout issues if not handled properly'
|
||||
const options = ['short', longText, 'normal']
|
||||
const widget = createMockWidget('short', { values: options })
|
||||
const wrapper = mountComponent(widget, 'short')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[1].text()).toBe(longText)
|
||||
})
|
||||
|
||||
it('handles large number of options', () => {
|
||||
const options = Array.from({ length: 20 }, (_, i) => `option${i + 1}`)
|
||||
const widget = createMockWidget('option5', { values: options })
|
||||
const wrapper = mountComponent(widget, 'option5')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(20)
|
||||
expect(buttons[4].classes()).toContain('bg-white') // option5 is at index 4
|
||||
})
|
||||
|
||||
it('handles duplicate options', () => {
|
||||
const options = ['duplicate', 'unique', 'duplicate', 'unique']
|
||||
const widget = createMockWidget('duplicate', { values: options })
|
||||
const wrapper = mountComponent(widget, 'duplicate')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(4)
|
||||
// Both 'duplicate' buttons should be highlighted (due to value matching)
|
||||
expect(buttons[0].classes()).toContain('bg-white')
|
||||
expect(buttons[2].classes()).toContain('bg-white')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling and Layout', () => {
|
||||
it('applies proper button styling', () => {
|
||||
const options = ['option1', 'option2']
|
||||
const widget = createMockWidget('option1', { values: options })
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button.classes()).toContain('flex-1')
|
||||
expect(button.classes()).toContain('h-6')
|
||||
expect(button.classes()).toContain('px-5')
|
||||
expect(button.classes()).toContain('rounded')
|
||||
expect(button.classes()).toContain('text-center')
|
||||
expect(button.classes()).toContain('text-xs')
|
||||
})
|
||||
})
|
||||
|
||||
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 })
|
||||
const wrapper = mountComponent(widget, 'option1', false)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const unselectedButton = buttons[1] // 'option2'
|
||||
|
||||
expect(unselectedButton.classes()).toContain('hover:bg-zinc-200/50')
|
||||
expect(unselectedButton.classes()).toContain('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration with Layout', () => {
|
||||
it('renders within WidgetLayoutField', () => {
|
||||
const widget = createMockWidget('test', { values: ['test'] })
|
||||
const wrapper = mountComponent(widget, 'test')
|
||||
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
expect(layoutField.exists()).toBe(true)
|
||||
expect(layoutField.props('widget')).toEqual(widget)
|
||||
})
|
||||
|
||||
it('passes widget name to layout field', () => {
|
||||
const widget = createMockWidget('test', { values: ['test'] })
|
||||
widget.name = 'custom_select_button'
|
||||
const wrapper = mountComponent(widget, 'test')
|
||||
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
expect(layoutField.props('widget').name).toBe('custom_select_button')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget="widget">
|
||||
<FormSelectButton
|
||||
v-model="localValue"
|
||||
:options="widget.options?.values || []"
|
||||
:disabled="readonly"
|
||||
class="w-full"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import FormSelectButton from './form/FormSelectButton.vue'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
modelValue: string
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useStringWidgetValue(
|
||||
props.widget,
|
||||
props.modelValue,
|
||||
emit
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget>
|
||||
<Select
|
||||
v-model="localValue"
|
||||
:options="selectOptions"
|
||||
v-bind="combinedProps"
|
||||
:disabled="readonly"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs'
|
||||
}"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | number | undefined>
|
||||
modelValue: string | number | undefined
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number | undefined]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: props.widget,
|
||||
modelValue: props.modelValue,
|
||||
defaultValue: props.widget.options?.values?.[0] || '',
|
||||
emit
|
||||
})
|
||||
|
||||
// Transform compatibility props for overlay positioning
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
const combinedProps = computed(() => ({
|
||||
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
|
||||
...transformCompatProps.value
|
||||
}))
|
||||
|
||||
// Extract select options from widget options
|
||||
const selectOptions = computed(() => {
|
||||
const options = props.widget.options
|
||||
|
||||
if (options?.values && Array.isArray(options.values)) {
|
||||
return options.values
|
||||
}
|
||||
|
||||
return []
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import FormDropdown from './form/dropdown/FormDropdown.vue'
|
||||
import type {
|
||||
DropdownItem,
|
||||
FilterOption,
|
||||
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
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number | undefined]
|
||||
}>()
|
||||
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: props.widget,
|
||||
modelValue: props.modelValue,
|
||||
defaultValue: props.widget.options?.values?.[0] || '',
|
||||
emit
|
||||
})
|
||||
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
const combinedProps = computed(() => ({
|
||||
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
|
||||
...transformCompatProps.value
|
||||
}))
|
||||
|
||||
const selectedSet = ref<Set<SelectedKey>>(new Set())
|
||||
const dropdownItems = computed<DropdownItem[]>(() => {
|
||||
const values = props.widget.options?.values || []
|
||||
|
||||
if (!Array.isArray(values)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return values.map((value: string, index: number) => ({
|
||||
id: index,
|
||||
imageSrc: getMediaUrl(value),
|
||||
name: value,
|
||||
metadata: ''
|
||||
}))
|
||||
})
|
||||
|
||||
const mediaPlaceholder = computed(() => {
|
||||
const options = props.widget.options
|
||||
|
||||
if (options?.placeholder) {
|
||||
return options.placeholder
|
||||
}
|
||||
|
||||
switch (props.assetKind) {
|
||||
case 'image':
|
||||
return t('widgets.uploadSelect.placeholderImage')
|
||||
case 'video':
|
||||
return t('widgets.uploadSelect.placeholderVideo')
|
||||
case 'audio':
|
||||
return t('widgets.uploadSelect.placeholderAudio')
|
||||
case 'model':
|
||||
return t('widgets.uploadSelect.placeholderModel')
|
||||
case 'unknown':
|
||||
return t('widgets.uploadSelect.placeholderUnknown')
|
||||
}
|
||||
|
||||
return t('widgets.uploadSelect.placeholder')
|
||||
})
|
||||
|
||||
const uploadable = computed(() => props.allowUpload === true)
|
||||
|
||||
watch(
|
||||
localValue,
|
||||
(currentValue) => {
|
||||
if (currentValue !== undefined) {
|
||||
const item = dropdownItems.value.find(
|
||||
(item) => item.name === currentValue
|
||||
)
|
||||
if (item) {
|
||||
selectedSet.value.clear()
|
||||
selectedSet.value.add(item.id)
|
||||
}
|
||||
} else {
|
||||
selectedSet.value.clear()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function updateSelectedItems(selectedItems: Set<SelectedKey>) {
|
||||
let id: SelectedKey | undefined = undefined
|
||||
if (selectedItems.size > 0) {
|
||||
id = selectedItems.values().next().value!
|
||||
}
|
||||
if (id == null) {
|
||||
onChange(undefined)
|
||||
return
|
||||
}
|
||||
const name = dropdownItems.value.find((item) => item.id === id)?.name
|
||||
if (!name) {
|
||||
onChange(undefined)
|
||||
return
|
||||
}
|
||||
onChange(name)
|
||||
}
|
||||
|
||||
// Upload file function (copied from useNodeImageUpload.ts)
|
||||
const uploadFile = async (
|
||||
file: File,
|
||||
isPasted: boolean = false,
|
||||
formFields: Partial<{ type: ResultItemType }> = {}
|
||||
) => {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (isPasted) body.append('subfolder', 'pasted')
|
||||
if (formFields.type) body.append('type', formFields.type)
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
toastStore.addAlert(resp.status + ' - ' + resp.statusText)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||
}
|
||||
|
||||
// Handle multiple file uploads
|
||||
const uploadFiles = async (files: File[]): Promise<string[]> => {
|
||||
const folder = props.uploadFolder ?? 'input'
|
||||
const uploadPromises = files.map((file) =>
|
||||
uploadFile(file, false, { type: folder })
|
||||
)
|
||||
const results = await Promise.all(uploadPromises)
|
||||
return results.filter((path): path is string => path !== null)
|
||||
}
|
||||
|
||||
async function handleFilesUpdate(files: File[]) {
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
try {
|
||||
// 1. Upload files to server
|
||||
const uploadedPaths = await uploadFiles(files)
|
||||
|
||||
if (uploadedPaths.length === 0) {
|
||||
toastStore.addAlert('File upload failed')
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Update widget options to include new files
|
||||
// This simulates what addToComboValues does but for SimplifiedWidget
|
||||
if (props.widget.options?.values) {
|
||||
uploadedPaths.forEach((path) => {
|
||||
const values = props.widget.options!.values as string[]
|
||||
if (!values.includes(path)) {
|
||||
values.push(path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Update widget value to the first uploaded file
|
||||
onChange(uploadedPaths[0])
|
||||
|
||||
// 4. Trigger callback to notify underlying LiteGraph widget
|
||||
if (props.widget.callback) {
|
||||
props.widget.callback(uploadedPaths[0])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
toastStore.addAlert(`Upload failed: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
// 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>
|
||||
<WidgetLayoutField :widget>
|
||||
<FormDropdown
|
||||
v-model:selected="selectedSet"
|
||||
v-model:filter-selected="filterSelected"
|
||||
:items="dropdownItems"
|
||||
:placeholder="mediaPlaceholder"
|
||||
:multiple="false"
|
||||
:uploadable="uploadable"
|
||||
:disabled="readonly"
|
||||
:filter-options="filterOptions"
|
||||
v-bind="combinedProps"
|
||||
class="w-full"
|
||||
@update:selected="updateSelectedItems"
|
||||
@update:files="handleFilesUpdate"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
@@ -0,0 +1,260 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetTextarea from './WidgetTextarea.vue'
|
||||
|
||||
function createMockWidget(
|
||||
value: string = 'default text',
|
||||
options: SimplifiedWidget['options'] = {},
|
||||
callback?: (value: string) => void
|
||||
): SimplifiedWidget<string> {
|
||||
return {
|
||||
name: 'test_textarea',
|
||||
type: 'string',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
}
|
||||
}
|
||||
|
||||
function mountComponent(
|
||||
widget: SimplifiedWidget<string>,
|
||||
modelValue: string,
|
||||
readonly = false,
|
||||
placeholder?: string
|
||||
) {
|
||||
return mount(WidgetTextarea, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { Textarea }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly,
|
||||
placeholder
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function setTextareaValueAndTrigger(
|
||||
wrapper: ReturnType<typeof mount>,
|
||||
value: string,
|
||||
trigger: 'blur' | 'input' = 'blur'
|
||||
) {
|
||||
const textarea = wrapper.find('textarea')
|
||||
if (!(textarea.element instanceof HTMLTextAreaElement)) {
|
||||
throw new Error(
|
||||
'Textarea element not found or is not an HTMLTextAreaElement'
|
||||
)
|
||||
}
|
||||
await textarea.setValue(value)
|
||||
await textarea.trigger(trigger)
|
||||
return textarea
|
||||
}
|
||||
|
||||
describe('WidgetTextarea Value Binding', () => {
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when textarea value changes on blur', async () => {
|
||||
const widget = createMockWidget('hello')
|
||||
const wrapper = mountComponent(widget, 'hello')
|
||||
|
||||
await setTextareaValueAndTrigger(wrapper, 'world', 'blur')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain('world')
|
||||
})
|
||||
|
||||
it('emits Vue event when textarea value changes on input', async () => {
|
||||
const widget = createMockWidget('initial')
|
||||
const wrapper = mountComponent(widget, 'initial')
|
||||
|
||||
await setTextareaValueAndTrigger(wrapper, 'new content', 'input')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain('new content')
|
||||
})
|
||||
|
||||
it('handles empty string values', async () => {
|
||||
const widget = createMockWidget('something')
|
||||
const wrapper = mountComponent(widget, 'something')
|
||||
|
||||
await setTextareaValueAndTrigger(wrapper, '')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain('')
|
||||
})
|
||||
|
||||
it('handles multiline text correctly', async () => {
|
||||
const widget = createMockWidget('single line')
|
||||
const wrapper = mountComponent(widget, 'single line')
|
||||
|
||||
const multilineText = 'Line 1\nLine 2\nLine 3'
|
||||
await setTextareaValueAndTrigger(wrapper, multilineText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain(multilineText)
|
||||
})
|
||||
|
||||
it('handles special characters correctly', async () => {
|
||||
const widget = createMockWidget('normal')
|
||||
const wrapper = mountComponent(widget, 'normal')
|
||||
|
||||
const specialText = 'special @#$%^&*()[]{}|\\:";\'<>?,./'
|
||||
await setTextareaValueAndTrigger(wrapper, specialText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain(specialText)
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget('test', {}, undefined)
|
||||
const wrapper = mountComponent(widget, 'test')
|
||||
|
||||
await setTextareaValueAndTrigger(wrapper, 'new value')
|
||||
|
||||
// Should still emit Vue event
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain('new value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('emits update:modelValue on blur', async () => {
|
||||
const widget = createMockWidget('original')
|
||||
const wrapper = mountComponent(widget, 'original')
|
||||
|
||||
await setTextareaValueAndTrigger(wrapper, 'updated')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain('updated')
|
||||
})
|
||||
|
||||
it('emits update:modelValue on input', async () => {
|
||||
const widget = createMockWidget('start')
|
||||
const wrapper = mountComponent(widget, 'start')
|
||||
|
||||
await setTextareaValueAndTrigger(wrapper, 'finish', 'input')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain('finish')
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
const wrapper = mountComponent(widget, 'test value')
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays initial value in textarea', () => {
|
||||
const widget = createMockWidget('initial content')
|
||||
const wrapper = mountComponent(widget, 'initial content')
|
||||
|
||||
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.value).toBe('initial content')
|
||||
})
|
||||
|
||||
it('uses widget name as placeholder when no placeholder provided', () => {
|
||||
const widget = createMockWidget('test')
|
||||
const wrapper = mountComponent(widget, 'test')
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.attributes('placeholder')).toBe('test_textarea')
|
||||
})
|
||||
|
||||
it('uses provided placeholder when specified', () => {
|
||||
const widget = createMockWidget('test')
|
||||
const wrapper = mountComponent(
|
||||
widget,
|
||||
'test',
|
||||
false,
|
||||
'Custom placeholder'
|
||||
)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.attributes('placeholder')).toBe('Custom placeholder')
|
||||
})
|
||||
|
||||
it('sets default rows attribute', () => {
|
||||
const widget = createMockWidget('test')
|
||||
const wrapper = mountComponent(widget, 'test')
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.attributes('rows')).toBe('3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles very long text', async () => {
|
||||
const widget = createMockWidget('short')
|
||||
const wrapper = mountComponent(widget, 'short')
|
||||
|
||||
const longText = 'a'.repeat(10000)
|
||||
await setTextareaValueAndTrigger(wrapper, longText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain(longText)
|
||||
})
|
||||
|
||||
it('handles unicode characters', async () => {
|
||||
const widget = createMockWidget('ascii')
|
||||
const wrapper = mountComponent(widget, 'ascii')
|
||||
|
||||
const unicodeText = '🎨 Unicode: αβγ 中文 العربية 🚀'
|
||||
await setTextareaValueAndTrigger(wrapper, unicodeText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain(unicodeText)
|
||||
})
|
||||
|
||||
it('handles text with tabs and spaces', async () => {
|
||||
const widget = createMockWidget('normal')
|
||||
const wrapper = mountComponent(widget, 'normal')
|
||||
|
||||
const formattedText = '\tIndented line\n Spaced line\n\t\tDouble indent'
|
||||
await setTextareaValueAndTrigger(wrapper, formattedText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain(formattedText)
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user