mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user