refactor: replace onBounding with layoutStore.collapsedSize

- Move min-height to root element (removes footer height accumulation)
- Remove onBounding callback and vueBoundsOverrides Map entirely
- Add collapsedSize field to layoutStore for collapsed node dimensions
- selectionBorder reads collapsedSize for collapsed nodes in Vue mode
- No litegraph core changes, no onBounding usage
This commit is contained in:
jaeone94
2026-03-31 20:28:10 +09:00
parent 8c7e629021
commit 87f394ac77
6 changed files with 93 additions and 113 deletions

View File

@@ -1,68 +1,93 @@
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas, Rectangle } from '@/lib/litegraph/src/litegraph'
import { createBounds } from '@/lib/litegraph/src/litegraph'
import { createBounds, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { app } from '@/scripts/app'
/**
* Draws a dashed border around selected items that maintains constant pixel size
* regardless of zoom level, similar to the DOM selection overlay.
*/
function getSelectionBounds(canvas: LGraphCanvas): ReadOnlyRect | null {
const selectedItems = canvas.selectedItems
if (selectedItems.size <= 1) return null
if (!LiteGraph.vueNodesMode) return createBounds(selectedItems, 10)
// In Vue mode, use layoutStore.collapsedSize for collapsed nodes
// to get accurate dimensions instead of litegraph's fallback values.
const padding = 10
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const item of selectedItems) {
const rect = item.boundingRect
const id = 'id' in item ? String(item.id) : null
const isCollapsed =
'flags' in item &&
!!(item as { flags?: { collapsed?: boolean } }).flags?.collapsed
const collapsedSize =
id && isCollapsed ? layoutStore.getNodeCollapsedSize(id) : undefined
if (collapsedSize) {
minX = Math.min(minX, rect[0])
minY = Math.min(minY, rect[1])
maxX = Math.max(maxX, rect[0] + collapsedSize.width)
maxY = Math.max(maxY, rect[1] + collapsedSize.height)
} else {
minX = Math.min(minX, rect[0])
minY = Math.min(minY, rect[1])
maxX = Math.max(maxX, rect[0] + rect[2])
maxY = Math.max(maxY, rect[1] + rect[3])
}
}
if (!Number.isFinite(minX)) return null
return [
minX - padding,
minY - padding,
maxX - minX + 2 * padding,
maxY - minY + 2 * padding
]
}
function drawSelectionBorder(
ctx: CanvasRenderingContext2D,
canvas: LGraphCanvas
) {
const selectedItems = canvas.selectedItems
// Only draw if multiple items selected
if (selectedItems.size <= 1) return
// Use the same bounds calculation as the toolbox
const bounds = createBounds(selectedItems, 10)
const bounds = getSelectionBounds(canvas)
if (!bounds) return
const [x, y, width, height] = bounds
// Save context state
ctx.save()
// Set up dashed line style that doesn't scale with zoom
const borderWidth = 2 / canvas.ds.scale // Constant 2px regardless of zoom
const borderWidth = 2 / canvas.ds.scale
ctx.lineWidth = borderWidth
ctx.strokeStyle =
getComputedStyle(document.documentElement)
.getPropertyValue('--border-color')
.trim() || '#ffffff66'
// Create dash pattern that maintains visual size
const dashSize = 5 / canvas.ds.scale
ctx.setLineDash([dashSize, dashSize])
// Draw the border using the bounds directly
ctx.beginPath()
ctx.roundRect(x, y, width, height, 8 / canvas.ds.scale)
ctx.stroke()
// Restore context
ctx.restore()
}
/**
* Extension that adds a dashed selection border for multiple selected nodes
*/
const ext = {
name: 'Comfy.SelectionBorder',
async init() {
// Hook into the canvas drawing
const originalDrawForeground = app.canvas.onDrawForeground
app.canvas.onDrawForeground = function (
ctx: CanvasRenderingContext2D,
visibleArea: Rectangle
) {
// Call original if it exists
originalDrawForeground?.call(this, ctx, visibleArea)
// Draw our selection border
drawSelectionBorder(ctx, app.canvas)
}
}

View File

@@ -34,6 +34,7 @@ import type {
NodeId,
NodeLayout,
Point,
Size,
RerouteId,
RerouteLayout,
ResizeNodeOperation,
@@ -129,6 +130,7 @@ class LayoutStoreImpl implements LayoutStore {
// CustomRef cache and trigger functions
private nodeRefs = new Map<NodeId, Ref<NodeLayout | null>>()
private nodeTriggers = new Map<NodeId, () => void>()
private collapsedSizes = new Map<NodeId, Size>()
// New data structures for hit testing
private linkLayouts = new Map<LinkId, LinkLayout>()
@@ -1546,6 +1548,18 @@ class LayoutStoreImpl implements LayoutStore {
this.currentSource = originalSource
}
updateNodeCollapsedSize(nodeId: NodeId, size: Size): void {
this.collapsedSizes.set(nodeId, size)
const nodeRef = this.getNodeLayoutRef(nodeId)
if (nodeRef.value) {
nodeRef.value = { ...nodeRef.value, collapsedSize: size }
}
}
getNodeCollapsedSize(nodeId: NodeId): Size | undefined {
return this.collapsedSizes.get(nodeId)
}
}
// Create singleton instance

View File

@@ -50,6 +50,9 @@ export interface NodeLayout {
visible: boolean
// Computed bounds for hit testing
bounds: Bounds
// Collapsed node dimensions (Vue mode only, separate from size to
// preserve expanded size across collapse/expand cycles)
collapsedSize?: Size
}
export interface SlotLayout {

View File

@@ -12,7 +12,9 @@
cn(
'group/node lg-node absolute isolate text-sm',
'flex flex-col contain-layout contain-style',
isRerouteNode ? 'h-(--node-height)' : 'min-w-(--min-node-width)',
isRerouteNode
? 'h-(--node-height)'
: 'min-h-(--node-height) min-w-(--min-node-width)',
cursorClass,
isSelected && 'outline-node-component-outline',
executing && 'outline-node-stroke-executing',
@@ -75,7 +77,7 @@
cn(
'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
'w-(--node-width)',
!isRerouteNode && 'min-h-(--node-height) min-w-(--min-node-width)',
!isRerouteNode && 'min-w-(--min-node-width)',
shapeClass,
hasAnyError && 'ring-4 ring-destructive-background',
{
@@ -256,8 +258,7 @@ import {
onMounted,
onUnmounted,
ref,
watch,
watchEffect
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
@@ -267,7 +268,7 @@ import { useAppMode } from '@/composables/useAppMode'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { hasUnpromotedWidgets } from '@/core/graph/subgraph/promotionUtils'
import { st } from '@/i18n'
import type { CompassCorners, Rect } from '@/lib/litegraph/src/interfaces'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import {
LGraphCanvas,
LGraphEventMode,
@@ -292,10 +293,7 @@ import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { usePartitionedBadges } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import {
useVueElementTracking,
vueBoundsOverrides
} from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
@@ -758,35 +756,6 @@ const showAdvancedState = customRef((track, trigger) => {
}
})
watchEffect((onCleanup) => {
const node = lgraphNode.value
if (!node) return
const nodeId = String(nodeData.id)
const collapsed = isCollapsed.value
const previousOnBounding = node.onBounding
const wrappedOnBounding = function (this: typeof node, out: Rect) {
previousOnBounding?.call(this, out)
const overrides = vueBoundsOverrides.get(nodeId)
if (!overrides) return
if (collapsed) {
if (overrides.collapsedWidth) out[2] = overrides.collapsedWidth
if (overrides.collapsedHeight) out[3] = overrides.collapsedHeight
} else if (overrides.footerHeight) {
out[3] += overrides.footerHeight
}
}
node.onBounding = wrappedOnBounding
onCleanup(() => {
if (node.onBounding === wrappedOnBounding) {
node.onBounding = previousOnBounding
}
})
})
const hasVideoInput = computed(() => {
return (
lgraphNode.value?.inputs?.some((input) => input.type === 'VIDEO') ?? false

View File

@@ -27,14 +27,6 @@ import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil
import { syncNodeSlotLayoutsFromDOM } from './useSlotElementTracking'
interface VueBoundsOverride {
footerHeight?: number
collapsedWidth?: number
collapsedHeight?: number
}
export const vueBoundsOverrides = new Map<NodeId, VueBoundsOverride>()
/**
* Generic update item for element bounds tracking
*/
@@ -147,50 +139,34 @@ const resizeObserver = new ResizeObserver((entries) => {
const nodeId: NodeId | undefined =
elementType === 'node' ? elementId : undefined
// Collapsed nodes: don't update layoutStore (preserve expanded size),
// but store collapsed dimensions in vueBoundsOverrides for onBounding.
// Collapsed nodes: preserve expanded size but store collapsed
// dimensions separately in layoutStore for selection bounds.
if (elementType === 'node' && element.dataset.collapsed != null) {
if (nodeId) {
markElementForFreshMeasurement(element)
const body = element.querySelector(
'[data-testid^="node-inner-wrapper"]'
)
vueBoundsOverrides.set(nodeId, {
...vueBoundsOverrides.get(nodeId),
collapsedWidth:
body instanceof HTMLElement
? body.offsetWidth
: element.offsetWidth,
collapsedHeight: element.offsetHeight
})
const collapsedWidth =
body instanceof HTMLElement ? body.offsetWidth : element.offsetWidth
const collapsedHeight = element.offsetHeight
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (nodeLayout) {
layoutStore.updateNodeCollapsedSize(nodeId, {
width: collapsedWidth,
height: collapsedHeight
})
}
nodesNeedingSlotResync.add(nodeId)
}
continue
}
// Measure body (node-inner-wrapper) to exclude footer height from
// node.size, preventing size accumulation on Vue/legacy mode switching.
const bodyEl = element.querySelector('[data-testid^="node-inner-wrapper"]')
const measuredEl = bodyEl instanceof HTMLElement ? bodyEl : element
const width = Math.max(0, measuredEl.offsetWidth)
const height = Math.max(0, measuredEl.offsetHeight)
const fullHeight = Math.max(0, element.offsetHeight)
// Store footer height in vueBoundsOverrides for onBounding
if (nodeId) {
const footerExtra = fullHeight - measuredEl.offsetHeight
if (footerExtra > 0) {
vueBoundsOverrides.set(nodeId, {
...vueBoundsOverrides.get(nodeId),
footerHeight: footerExtra
})
} else {
const existing = vueBoundsOverrides.get(nodeId)
if (existing?.footerHeight) {
existing.footerHeight = undefined
}
}
}
// Measure the full root element (including footer in flow).
// min-height is applied to the root, so footer height in node.size
// does not accumulate on Vue/legacy mode switching.
const width = Math.max(0, element.offsetWidth)
const height = Math.max(0, element.offsetHeight)
const nodeLayout = nodeId
? layoutStore.getNodeLayoutRef(nodeId).value
@@ -331,9 +307,5 @@ export function useVueElementTracking(
cachedNodeMeasurements.delete(element)
elementsNeedingFreshMeasurement.delete(element)
resizeObserver.unobserve(element)
if (trackingType === 'node' && appIdentifier) {
vueBoundsOverrides.delete(appIdentifier as NodeId)
}
})
}

View File

@@ -51,10 +51,7 @@ export function useNodeResize(
const nodeId = nodeElement.dataset.nodeId
if (!nodeId) return
const bodyElement =
nodeElement.querySelector('[data-testid^="node-inner-wrapper"]') ??
nodeElement
const rect = bodyElement.getBoundingClientRect()
const rect = nodeElement.getBoundingClientRect()
const scale = transformState.camera.z
const startSize: Size = {
@@ -64,7 +61,7 @@ export function useNodeResize(
const savedNodeHeight = nodeElement.style.getPropertyValue('--node-height')
nodeElement.style.setProperty('--node-height', '0px')
const minContentHeight = bodyElement.getBoundingClientRect().height / scale
const minContentHeight = nodeElement.getBoundingClientRect().height / scale
nodeElement.style.setProperty('--node-height', savedNodeHeight || '')
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value