mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 21:38:52 +00:00
Compare commits
5 Commits
glary/fe-5
...
austin/nod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
168fa5295e | ||
|
|
63eafe8f15 | ||
|
|
8f91f1aada | ||
|
|
b2c47928fb | ||
|
|
bcf7f1d2ad |
@@ -5,7 +5,6 @@
|
||||
import { reactiveComputed } from '@vueuse/core'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
@@ -581,58 +580,23 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
originalCallback?: (node: LGraphNode) => void
|
||||
) => {
|
||||
const id = String(node.id)
|
||||
|
||||
// Store non-reactive reference to original node
|
||||
nodeRefs.set(id, node)
|
||||
|
||||
// Extract initial data for Vue (may be incomplete during graph configure)
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
|
||||
const initializeVueNodeLayout = () => {
|
||||
// Check if the node was removed mid-sequence
|
||||
if (!nodeRefs.has(id)) return
|
||||
const existingLayout = layoutStore.getNodeLayoutRef(id).value
|
||||
if (existingLayout) return originalCallback?.(node)
|
||||
|
||||
// Extract actual positions after configure() has potentially updated them
|
||||
const nodePosition = { x: node.pos[0], y: node.pos[1] }
|
||||
const nodeSize = { width: node.size[0], height: node.size[1] }
|
||||
const nodePosition = { x: node.pos[0], y: node.pos[1] }
|
||||
const nodeSize = { width: node.size[0], height: node.size[1] }
|
||||
setSource(LayoutSource.Canvas)
|
||||
void createNode(id, {
|
||||
position: nodePosition,
|
||||
size: nodeSize,
|
||||
zIndex: node.order || 0,
|
||||
visible: true
|
||||
})
|
||||
|
||||
// Skip layout creation if it already exists
|
||||
// (e.g. in-place node replacement where the old node's layout is reused for the new node with the same ID).
|
||||
const existingLayout = layoutStore.getNodeLayoutRef(id).value
|
||||
if (existingLayout) return
|
||||
|
||||
// Add node to layout store with final positions
|
||||
setSource(LayoutSource.Canvas)
|
||||
void createNode(id, {
|
||||
position: nodePosition,
|
||||
size: nodeSize,
|
||||
zIndex: node.order || 0,
|
||||
visible: true
|
||||
})
|
||||
}
|
||||
|
||||
// Check if we're in the middle of configuring the graph (workflow loading)
|
||||
if (window.app?.configuringGraph) {
|
||||
// During workflow loading - defer layout initialization until configure completes
|
||||
// Chain our callback with any existing onAfterGraphConfigured callback
|
||||
node.onAfterGraphConfigured = useChainCallback(
|
||||
node.onAfterGraphConfigured,
|
||||
() => {
|
||||
// Re-extract data now that configure() has populated title/slots/widgets/etc.
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
initializeVueNodeLayout()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Not during workflow loading - initialize layout immediately
|
||||
// This handles individual node additions during normal operation
|
||||
initializeVueNodeLayout()
|
||||
}
|
||||
|
||||
// Call original callback if provided
|
||||
if (originalCallback) {
|
||||
void originalCallback(node)
|
||||
}
|
||||
return originalCallback?.(node)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,6 @@ import { createI18n } from 'vue-i18n'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -49,13 +48,6 @@ vi.mock(
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking',
|
||||
() => ({
|
||||
useVueElementTracking: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: { getNodeById: vi.fn() },
|
||||
@@ -188,12 +180,6 @@ describe('LGraphNode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should call resize tracking composable with node ID', () => {
|
||||
renderLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
expect(useVueElementTracking).toHaveBeenCalledWith('test-node-123', 'node')
|
||||
})
|
||||
|
||||
it('should render with data-node-id attribute', () => {
|
||||
const { container } = renderLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
|
||||
@@ -293,7 +293,6 @@ 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 } 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'
|
||||
@@ -343,8 +342,6 @@ const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
|
||||
useNodeEventHandlers()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
|
||||
useVueElementTracking(String(nodeData.id), 'node')
|
||||
|
||||
const { selectedNodeIds, isGhostPlacing } = storeToRefs(useCanvasStore())
|
||||
const isSelected = computed(() => {
|
||||
return selectedNodeIds.value.has(nodeData.id)
|
||||
@@ -464,6 +461,25 @@ function initSizeStyles() {
|
||||
el.style.setProperty(`--node-height${suffix}`, `${fullHeight}px`)
|
||||
}
|
||||
|
||||
function syncSizeToLayoutStore() {
|
||||
//Write size back to layout store
|
||||
//This happens prior to layout subscription so feedback loops can not occur
|
||||
//Still dangerous
|
||||
const el = nodeContainerRef.value
|
||||
if (!el) return
|
||||
|
||||
const bounds = el.getBoundingClientRect()
|
||||
const width = Math.max(bounds.width - 1, size.value.width)
|
||||
const height = Math.max(
|
||||
bounds.height - 1 - LiteGraph.NODE_TITLE_HEIGHT,
|
||||
size.value.height
|
||||
)
|
||||
if (size.value.height !== height || size.value.width !== width) {
|
||||
mutations.setSource(LayoutSource.Vue)
|
||||
mutations.resizeNode(nodeData.id, { width, height })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle external size changes (e.g., from extensions calling node.setSize()).
|
||||
* Updates CSS variables when layoutStore changes from Canvas/External source.
|
||||
@@ -491,6 +507,8 @@ let unsubscribeLayoutChange: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
initSizeStyles()
|
||||
syncSizeToLayoutStore()
|
||||
|
||||
unsubscribeLayoutChange = layoutStore.onNodeChange(
|
||||
nodeData.id,
|
||||
handleLayoutChange
|
||||
@@ -517,10 +535,9 @@ const { startResize } = useNodeResize((result, element) => {
|
||||
element.style.setProperty('--node-height', `${result.size.height}px`)
|
||||
|
||||
// Update position for non-SE corner resizing
|
||||
if (result.position) {
|
||||
mutations.setSource(LayoutSource.Vue)
|
||||
mutations.moveNode(nodeData.id, result.position)
|
||||
}
|
||||
mutations.setSource(LayoutSource.Vue)
|
||||
mutations.resizeNode(nodeData.id, result.size)
|
||||
if (result.position) mutations.moveNode(nodeData.id, result.position)
|
||||
})
|
||||
|
||||
const handleResizePointerDown = (
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeId, NodeLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
type ResizeEntryLike = Pick<
|
||||
ResizeObserverEntry,
|
||||
| 'target'
|
||||
| 'borderBoxSize'
|
||||
| 'contentBoxSize'
|
||||
| 'devicePixelContentBoxSize'
|
||||
| 'contentRect'
|
||||
>
|
||||
|
||||
const resizeObserverState = vi.hoisted(() => {
|
||||
const state = {
|
||||
callback: null as ResizeObserverCallback | null,
|
||||
observe: vi.fn<(element: Element) => void>(),
|
||||
unobserve: vi.fn<(element: Element) => void>(),
|
||||
disconnect: vi.fn<() => void>()
|
||||
}
|
||||
|
||||
const MockResizeObserver: typeof ResizeObserver = class MockResizeObserver implements ResizeObserver {
|
||||
observe = state.observe
|
||||
unobserve = state.unobserve
|
||||
disconnect = state.disconnect
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
state.callback = callback
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = MockResizeObserver
|
||||
|
||||
return state
|
||||
})
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
linearMode: false,
|
||||
nodeLayouts: new Map<NodeId, NodeLayout>(),
|
||||
batchUpdateNodeBounds: vi.fn(),
|
||||
setSource: vi.fn(),
|
||||
syncNodeSlotLayoutsFromDOM: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useDocumentVisibility: () => ref<'visible' | 'hidden'>('visible'),
|
||||
createSharedComposable: <T>(fn: T) => fn
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
linearMode: testState.linearMode
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
|
||||
useSharedCanvasPositionConversion: () => ({
|
||||
clientPosToCanvasPos: ([x, y]: [number, number]) => [x, y]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
batchUpdateNodeBounds: testState.batchUpdateNodeBounds,
|
||||
setSource: testState.setSource,
|
||||
getNodeLayoutRef: (nodeId: NodeId): Ref<NodeLayout | null> =>
|
||||
ref<NodeLayout | null>(testState.nodeLayouts.get(nodeId) ?? null)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./useSlotElementTracking', () => ({
|
||||
syncNodeSlotLayoutsFromDOM: testState.syncNodeSlotLayoutsFromDOM
|
||||
}))
|
||||
|
||||
import './useVueNodeResizeTracking'
|
||||
|
||||
function createResizeEntry(options?: {
|
||||
nodeId?: NodeId
|
||||
width?: number
|
||||
height?: number
|
||||
left?: number
|
||||
top?: number
|
||||
collapsed?: boolean
|
||||
}) {
|
||||
const {
|
||||
nodeId = 'test-node',
|
||||
width = 240,
|
||||
height = 180,
|
||||
left = 100,
|
||||
top = 200,
|
||||
collapsed = false
|
||||
} = options ?? {}
|
||||
|
||||
const element = document.createElement('div')
|
||||
element.dataset.nodeId = nodeId
|
||||
if (collapsed) {
|
||||
element.dataset.collapsed = ''
|
||||
}
|
||||
const rectSpy = vi.fn(() => new DOMRect(left, top, width, height))
|
||||
element.getBoundingClientRect = rectSpy
|
||||
const boxSizes = [{ inlineSize: width, blockSize: height }]
|
||||
|
||||
const entry = {
|
||||
target: element,
|
||||
borderBoxSize: boxSizes,
|
||||
contentBoxSize: boxSizes,
|
||||
devicePixelContentBoxSize: boxSizes,
|
||||
contentRect: new DOMRect(left, top, width, height)
|
||||
} satisfies ResizeEntryLike
|
||||
|
||||
return {
|
||||
entry,
|
||||
rectSpy
|
||||
}
|
||||
}
|
||||
|
||||
function createObserverMock(): ResizeObserver {
|
||||
return {
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function seedNodeLayout(options: {
|
||||
nodeId: NodeId
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}) {
|
||||
const { nodeId, left, top, width, height } = options
|
||||
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||
const contentHeight = height - titleHeight
|
||||
|
||||
testState.nodeLayouts.set(nodeId, {
|
||||
id: nodeId,
|
||||
position: { x: left, y: top + titleHeight },
|
||||
size: { width, height: contentHeight },
|
||||
zIndex: 0,
|
||||
visible: true,
|
||||
bounds: {
|
||||
x: left,
|
||||
y: top + titleHeight,
|
||||
width,
|
||||
height: contentHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('useVueNodeResizeTracking', () => {
|
||||
beforeEach(() => {
|
||||
testState.linearMode = false
|
||||
testState.nodeLayouts.clear()
|
||||
testState.batchUpdateNodeBounds.mockReset()
|
||||
testState.setSource.mockReset()
|
||||
testState.syncNodeSlotLayoutsFromDOM.mockReset()
|
||||
resizeObserverState.observe.mockReset()
|
||||
resizeObserverState.unobserve.mockReset()
|
||||
resizeObserverState.disconnect.mockReset()
|
||||
})
|
||||
|
||||
it('skips repeated no-op resize entries after first measurement', () => {
|
||||
const nodeId = 'test-node'
|
||||
const width = 240
|
||||
const height = 180
|
||||
const left = 100
|
||||
const top = 200
|
||||
const { entry, rectSpy } = createResizeEntry({
|
||||
nodeId,
|
||||
width,
|
||||
height,
|
||||
left,
|
||||
top
|
||||
})
|
||||
|
||||
seedNodeLayout({ nodeId, left, top, width, height })
|
||||
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
// When layout store already has correct position, getBoundingClientRect
|
||||
// is not needed — position is read from the store instead.
|
||||
expect(rectSpy).not.toHaveBeenCalled()
|
||||
expect(testState.setSource).not.toHaveBeenCalled()
|
||||
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
|
||||
expect(testState.syncNodeSlotLayoutsFromDOM).not.toHaveBeenCalled()
|
||||
|
||||
testState.setSource.mockReset()
|
||||
testState.batchUpdateNodeBounds.mockReset()
|
||||
testState.syncNodeSlotLayoutsFromDOM.mockReset()
|
||||
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
expect(rectSpy).not.toHaveBeenCalled()
|
||||
expect(testState.setSource).not.toHaveBeenCalled()
|
||||
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
|
||||
expect(testState.syncNodeSlotLayoutsFromDOM).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves layout store position when size matches but DOM position differs', () => {
|
||||
const nodeId = 'test-node'
|
||||
const width = 240
|
||||
const height = 180
|
||||
const { entry, rectSpy } = createResizeEntry({
|
||||
nodeId,
|
||||
width,
|
||||
height,
|
||||
left: 100,
|
||||
top: 200
|
||||
})
|
||||
|
||||
seedNodeLayout({
|
||||
nodeId,
|
||||
left: 90,
|
||||
top: 190,
|
||||
width,
|
||||
height
|
||||
})
|
||||
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
// Position from DOM should NOT override layout store position
|
||||
expect(rectSpy).not.toHaveBeenCalled()
|
||||
expect(testState.setSource).not.toHaveBeenCalled()
|
||||
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates node bounds + slot layouts when size changes', () => {
|
||||
const nodeId = 'test-node'
|
||||
const { entry } = createResizeEntry({
|
||||
nodeId,
|
||||
width: 240,
|
||||
height: 180,
|
||||
left: 100,
|
||||
top: 200
|
||||
})
|
||||
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||
|
||||
seedNodeLayout({
|
||||
nodeId,
|
||||
left: 100,
|
||||
top: 200,
|
||||
width: 220,
|
||||
height: 140
|
||||
})
|
||||
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
|
||||
expect(testState.batchUpdateNodeBounds).toHaveBeenCalledWith([
|
||||
{
|
||||
nodeId,
|
||||
bounds: {
|
||||
x: 100,
|
||||
y: 200 + titleHeight,
|
||||
width: 240,
|
||||
height: 180
|
||||
}
|
||||
}
|
||||
])
|
||||
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
|
||||
})
|
||||
|
||||
it('writes collapsed dimensions through the normal bounds path', () => {
|
||||
const nodeId = 'test-node'
|
||||
const collapsedWidth = 200
|
||||
const collapsedHeight = 40
|
||||
const { entry } = createResizeEntry({
|
||||
nodeId,
|
||||
width: collapsedWidth,
|
||||
height: collapsedHeight,
|
||||
left: 100,
|
||||
top: 200,
|
||||
collapsed: true
|
||||
})
|
||||
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||
|
||||
// Seed with larger expanded size so the collapsed write is a real change
|
||||
seedNodeLayout({ nodeId, left: 100, top: 200, width: 240, height: 180 })
|
||||
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
|
||||
expect(testState.batchUpdateNodeBounds).toHaveBeenCalledWith([
|
||||
{
|
||||
nodeId,
|
||||
bounds: {
|
||||
x: 100,
|
||||
y: 200 + titleHeight,
|
||||
width: collapsedWidth,
|
||||
height: collapsedHeight
|
||||
}
|
||||
}
|
||||
])
|
||||
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
|
||||
})
|
||||
|
||||
it('updates bounds with expanded dimensions on collapse-to-expand transition', () => {
|
||||
const nodeId = 'test-node'
|
||||
|
||||
// Seed with smaller (collapsed) size so expand triggers a real bounds update
|
||||
seedNodeLayout({ nodeId, left: 100, top: 200, width: 200, height: 10 })
|
||||
|
||||
const { entry } = createResizeEntry({
|
||||
nodeId,
|
||||
width: 240,
|
||||
height: 180,
|
||||
left: 100,
|
||||
top: 200
|
||||
})
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
|
||||
expect(testState.batchUpdateNodeBounds).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,305 +0,0 @@
|
||||
/**
|
||||
* 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 { getCurrentInstance, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import { useDocumentVisibility } from '@vueuse/core'
|
||||
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import {
|
||||
isBoundsEqual,
|
||||
isSizeEqual
|
||||
} from '@/renderer/core/layout/utils/geometry'
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
interface CachedNodeMeasurement {
|
||||
nodeId: NodeId
|
||||
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)
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
// Elements whose ResizeObserver fired while the tab was hidden
|
||||
const deferredElements = new Set<HTMLElement>()
|
||||
const elementsNeedingFreshMeasurement = new WeakSet<HTMLElement>()
|
||||
const cachedNodeMeasurements = new WeakMap<HTMLElement, CachedNodeMeasurement>()
|
||||
const visibility = useDocumentVisibility()
|
||||
|
||||
function markElementForFreshMeasurement(element: HTMLElement) {
|
||||
elementsNeedingFreshMeasurement.add(element)
|
||||
cachedNodeMeasurements.delete(element)
|
||||
}
|
||||
|
||||
watch(visibility, (state) => {
|
||||
if (state !== 'visible' || deferredElements.size === 0) return
|
||||
|
||||
// Re-observe deferred elements to trigger fresh measurements
|
||||
for (const element of deferredElements) {
|
||||
if (element.isConnected) {
|
||||
markElementForFreshMeasurement(element)
|
||||
resizeObserver.observe(element)
|
||||
}
|
||||
}
|
||||
deferredElements.clear()
|
||||
})
|
||||
|
||||
// Single ResizeObserver instance for all Vue elements
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (useCanvasStore().linearMode) return
|
||||
|
||||
// Skip measurements when tab is hidden — bounding rects are unreliable
|
||||
if (visibility.value === 'hidden') {
|
||||
for (const entry of entries) {
|
||||
if (entry.target instanceof HTMLElement) {
|
||||
deferredElements.add(entry.target)
|
||||
markElementForFreshMeasurement(entry.target)
|
||||
resizeObserver.unobserve(entry.target)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 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<NodeId>()
|
||||
|
||||
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
|
||||
const nodeId: NodeId | undefined =
|
||||
elementType === 'node' ? elementId : undefined
|
||||
|
||||
// Use borderBoxSize when available; fall back to contentRect for older engines/tests
|
||||
// Border box is the border included FULL wxh DOM value.
|
||||
const borderBox = Array.isArray(entry.borderBoxSize)
|
||||
? entry.borderBoxSize[0]
|
||||
: {
|
||||
inlineSize: entry.contentRect.width,
|
||||
blockSize: entry.contentRect.height
|
||||
}
|
||||
const width = Math.max(0, borderBox.inlineSize)
|
||||
const height = Math.max(0, borderBox.blockSize)
|
||||
|
||||
const nodeLayout = nodeId
|
||||
? layoutStore.getNodeLayoutRef(nodeId).value
|
||||
: null
|
||||
const normalizedHeight = removeNodeTitleHeight(height)
|
||||
const previousMeasurement = cachedNodeMeasurements.get(element)
|
||||
const hasFreshMeasurementPending =
|
||||
elementsNeedingFreshMeasurement.has(element)
|
||||
const hasMatchingCachedNodeMeasurement =
|
||||
previousMeasurement != null &&
|
||||
previousMeasurement.nodeId === nodeId &&
|
||||
nodeLayout != null &&
|
||||
isBoundsEqual(previousMeasurement.bounds, nodeLayout.bounds)
|
||||
|
||||
// ResizeObserver emits entries where nothing changed (e.g. initial observe).
|
||||
// Skip expensive DOM reads when this exact element/node already measured at
|
||||
// the same normalized bounds and size.
|
||||
if (
|
||||
nodeLayout &&
|
||||
!hasFreshMeasurementPending &&
|
||||
isSizeEqual(nodeLayout.size, {
|
||||
width,
|
||||
height: normalizedHeight
|
||||
}) &&
|
||||
hasMatchingCachedNodeMeasurement
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Use existing position from layout store (source of truth) rather than
|
||||
// converting screen-space getBoundingClientRect() back to canvas coords.
|
||||
// The DOM→canvas conversion depends on the current canvas scale/offset,
|
||||
// which can be stale during graph transitions (e.g. entering a subgraph
|
||||
// before fitView runs), producing corrupted positions.
|
||||
const existingPos = nodeLayout?.position
|
||||
let posX: number
|
||||
let posY: number
|
||||
if (existingPos) {
|
||||
posX = existingPos.x
|
||||
posY = existingPos.y
|
||||
} else {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const [cx, cy] = conv.clientPosToCanvasPos([rect.left, rect.top])
|
||||
posX = cx
|
||||
posY = cy + LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
const bounds: Bounds = {
|
||||
x: posX,
|
||||
y: posY,
|
||||
width,
|
||||
height
|
||||
}
|
||||
const normalizedBounds: Bounds = {
|
||||
...bounds,
|
||||
height: normalizedHeight
|
||||
}
|
||||
|
||||
elementsNeedingFreshMeasurement.delete(element)
|
||||
if (nodeId) {
|
||||
cachedNodeMeasurements.set(element, {
|
||||
nodeId,
|
||||
bounds: normalizedBounds
|
||||
})
|
||||
}
|
||||
|
||||
if (nodeLayout && isBoundsEqual(nodeLayout.bounds, normalizedBounds)) {
|
||||
continue
|
||||
}
|
||||
|
||||
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 (nodeId) {
|
||||
nodesNeedingSlotResync.add(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
if (updatesByType.size === 0 && nodesNeedingSlotResync.size === 0) return
|
||||
|
||||
if (updatesByType.size > 0) {
|
||||
layoutStore.setSource(LayoutSource.DOM)
|
||||
|
||||
// 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(
|
||||
appIdentifier: string,
|
||||
trackingType: string
|
||||
) {
|
||||
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
|
||||
markElementForFreshMeasurement(element)
|
||||
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]
|
||||
cachedNodeMeasurements.delete(element)
|
||||
elementsNeedingFreshMeasurement.delete(element)
|
||||
deferredElements.delete(element)
|
||||
resizeObserver.unobserve(element)
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { computed, onUnmounted, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { Point } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Composable for individual Vue node components
|
||||
@@ -12,7 +9,6 @@ import type { Point } from '@/renderer/core/layout/types'
|
||||
*/
|
||||
export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
const nodeId = toValue(nodeIdMaybe)
|
||||
const mutations = useLayoutMutations()
|
||||
|
||||
// Get the customRef for this node (shared write access)
|
||||
const layoutRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
@@ -34,21 +30,9 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
|
||||
const zIndex = computed(() => layoutRef.value?.zIndex ?? 0)
|
||||
|
||||
/**
|
||||
* Update node position directly (without drag)
|
||||
*/
|
||||
function moveNodeTo(position: Point) {
|
||||
mutations.setSource(LayoutSource.Vue)
|
||||
mutations.moveNode(nodeId, position)
|
||||
}
|
||||
|
||||
return {
|
||||
// Reactive state (via customRef)
|
||||
position,
|
||||
size,
|
||||
zIndex,
|
||||
|
||||
// Mutations
|
||||
moveNodeTo
|
||||
zIndex
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user