refactor: route collapsed size through layoutStore and measure()

Per review feedback, replace the parallel collapsedSizes Map with a
first-class collapsedSize field stored directly in ynodes. measure()
reads collapsed dimensions via a LiteGraph.getCollapsedSize accessor
injected by the Vue layer, making boundingRect automatically correct.

- Add LiteGraph.getCollapsedSize accessor (dependency injection)
- measure() uses accessor for collapsed nodes when available
- Store collapsedSize in ynode via mappers (layoutToYNode/yNodeToLayout)
- Remove separate collapsedSizes Map and getter spread-merge
- getNodeCollapsedSize reads directly from ynode (no yNodeToLayout)
- clearNodeCollapsedSize on expand to prevent stale data
- Restore selectionBorder.ts to original createBounds (no special path)
- useVueFeatureFlags injects accessor on Vue mode enable
This commit is contained in:
jaeone94
2026-04-08 16:42:28 +09:00
parent 82997f88c5
commit 9cb0d21fd7
9 changed files with 83 additions and 71 deletions

View File

@@ -6,6 +6,7 @@ import { createSharedComposable } from '@vueuse/core'
import { computed, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LiteGraph } from '../lib/litegraph/src/litegraph'
@@ -25,6 +26,10 @@ function useVueFeatureFlagsIndividual() {
shouldRenderVueNodes,
() => {
LiteGraph.vueNodesMode = shouldRenderVueNodes.value
LiteGraph.getCollapsedSize = shouldRenderVueNodes.value
? (nodeId) =>
layoutStore.getNodeCollapsedSize(String(nodeId)) ?? undefined
: undefined
},
{ immediate: true }
)

View File

@@ -1,93 +1,68 @@
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas, Rectangle } from '@/lib/litegraph/src/litegraph'
import { createBounds, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { createBounds } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
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
]
}
/**
* Draws a dashed border around selected items that maintains constant pixel size
* regardless of zoom level, similar to the DOM selection overlay.
*/
function drawSelectionBorder(
ctx: CanvasRenderingContext2D,
canvas: LGraphCanvas
) {
const bounds = getSelectionBounds(canvas)
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)
if (!bounds) return
const [x, y, width, height] = bounds
// Save context state
ctx.save()
const borderWidth = 2 / canvas.ds.scale
// Set up dashed line style that doesn't scale with zoom
const borderWidth = 2 / canvas.ds.scale // Constant 2px regardless of zoom
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

@@ -2092,16 +2092,22 @@ export class LGraphNode
out[2] = this.size[0]
out[3] = this.size[1] + titleHeight
} else {
if (ctx) ctx.font = this.innerFontStyle
this._collapsed_width = Math.min(
this.size[0],
ctx
? cachedMeasureText(ctx, this.getTitle() ?? '') +
LiteGraph.NODE_TITLE_HEIGHT * 2
: 0
)
out[2] = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
out[3] = LiteGraph.NODE_TITLE_HEIGHT
const collapsedSize = LiteGraph.getCollapsedSize?.(this.id)
if (collapsedSize) {
out[2] = collapsedSize.width
out[3] = collapsedSize.height
} else {
if (ctx) ctx.font = this.innerFontStyle
this._collapsed_width = Math.min(
this.size[0],
ctx
? cachedMeasureText(ctx, this.getTitle() ?? '') +
LiteGraph.NODE_TITLE_HEIGHT * 2
: 0
)
out[2] = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
out[3] = LiteGraph.NODE_TITLE_HEIGHT
}
}
}

View File

@@ -355,6 +355,15 @@ export class LiteGraphGlobal {
*/
vueNodesMode: boolean = false
/**
* Optional accessor for collapsed node dimensions in Vue mode.
* Set by the Vue layer to provide DOM-measured collapsed sizes
* that measure() can use instead of canvas text measurement.
*/
getCollapsedSize?: (
nodeId: string | number
) => { width: number; height: number } | undefined
// Special Rendering Values pulled out of app.ts patches
nodeOpacity = 1
nodeLightness: number | undefined = undefined

View File

@@ -155,6 +155,7 @@ LiteGraphGlobal {
"dialog_close_on_mouse_leave_delay": 500,
"distance": [Function],
"do_add_triggers_slots": false,
"getCollapsedSize": undefined,
"highlight_selected_group": true,
"isInsideRectangle": [Function],
"leftMouseClickBehavior": "panning",

View File

@@ -130,7 +130,6 @@ 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>()
@@ -243,11 +242,7 @@ class LayoutStoreImpl implements LayoutStore {
get: () => {
track()
const ynode = this.ynodes.get(nodeId)
const layout = ynode ? yNodeToLayout(ynode) : null
const collapsedSize = this.collapsedSizes.get(nodeId)
return layout && collapsedSize
? { ...layout, collapsedSize }
: layout
return ynode ? yNodeToLayout(ynode) : null
},
set: (newLayout: NodeLayout | null) => {
if (newLayout === null) {
@@ -1006,7 +1001,6 @@ class LayoutStoreImpl implements LayoutStore {
): void {
this.ydoc.transact(() => {
this.ynodes.clear()
this.collapsedSizes.clear()
// Note: We intentionally do NOT clear nodeRefs and nodeTriggers here.
// Vue components may already hold references to these refs, and clearing
// them would break the reactivity chain. The refs will be reused when
@@ -1554,12 +1548,22 @@ class LayoutStoreImpl implements LayoutStore {
}
updateNodeCollapsedSize(nodeId: NodeId, size: Size): void {
this.collapsedSizes.set(nodeId, size)
const ynode = this.ynodes.get(nodeId)
if (!ynode) return
ynode.set('collapsedSize', size)
this.nodeTriggers.get(nodeId)?.()
}
getNodeCollapsedSize(nodeId: NodeId): Size | undefined {
return this.collapsedSizes.get(nodeId)
return this.ynodes.get(nodeId)?.get('collapsedSize') as Size | undefined
}
clearNodeCollapsedSize(nodeId: NodeId): void {
const ynode = this.ynodes.get(nodeId)
if (ynode?.has('collapsedSize')) {
ynode.delete('collapsedSize')
this.nodeTriggers.get(nodeId)?.()
}
}
}

View File

@@ -21,6 +21,7 @@ export function layoutToYNode(layout: NodeLayout): NodeLayoutMap {
ynode.set('zIndex', layout.zIndex)
ynode.set('visible', layout.visible)
ynode.set('bounds', layout.bounds)
if (layout.collapsedSize) ynode.set('collapsedSize', layout.collapsedSize)
return ynode
}
@@ -34,7 +35,7 @@ function getOr<K extends keyof NodeLayout>(
}
export function yNodeToLayout(ynode: NodeLayoutMap): NodeLayout {
return {
const layout: NodeLayout = {
id: getOr(ynode, 'id', NODE_LAYOUT_DEFAULTS.id),
position: getOr(ynode, 'position', NODE_LAYOUT_DEFAULTS.position),
size: getOr(ynode, 'size', NODE_LAYOUT_DEFAULTS.size),
@@ -42,4 +43,8 @@ export function yNodeToLayout(ynode: NodeLayoutMap): NodeLayout {
visible: getOr(ynode, 'visible', NODE_LAYOUT_DEFAULTS.visible),
bounds: getOr(ynode, 'bounds', NODE_LAYOUT_DEFAULTS.bounds)
}
const collapsedSize = ynode.get('collapsedSize')
if (collapsedSize)
layout.collapsedSize = collapsedSize as NodeLayout['collapsedSize']
return layout
}

View File

@@ -68,7 +68,9 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
batchUpdateNodeBounds: testState.batchUpdateNodeBounds,
setSource: testState.setSource,
getNodeLayoutRef: (nodeId: NodeId): Ref<NodeLayout | null> =>
ref<NodeLayout | null>(testState.nodeLayouts.get(nodeId) ?? null)
ref<NodeLayout | null>(testState.nodeLayouts.get(nodeId) ?? null),
clearNodeCollapsedSize: vi.fn(),
updateNodeCollapsedSize: vi.fn()
}
}))

View File

@@ -161,6 +161,11 @@ const resizeObserver = new ResizeObserver((entries) => {
continue
}
// Clear stale collapsedSize when node is expanded
if (elementType === 'node' && nodeId) {
layoutStore.clearNodeCollapsedSize(nodeId)
}
// 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.