diff --git a/src/composables/canvas/useSelectionToolboxPosition.ts b/src/composables/canvas/useSelectionToolboxPosition.ts index 0ea92b92e5..963f9c7d2d 100644 --- a/src/composables/canvas/useSelectionToolboxPosition.ts +++ b/src/composables/canvas/useSelectionToolboxPosition.ts @@ -102,6 +102,8 @@ export function useSelectionToolboxPosition( worldPosition.value = { x: unionBounds.x + unionBounds.width / 2, + // createBounds() applied a default padding of 10px + // so adjust Y to maintain visual consistency y: unionBounds.y - 10 } diff --git a/src/composables/element/useCanvasPositionConversion.ts b/src/composables/element/useCanvasPositionConversion.ts index e7fb9ad9bf..700b64ac46 100644 --- a/src/composables/element/useCanvasPositionConversion.ts +++ b/src/composables/element/useCanvasPositionConversion.ts @@ -1,6 +1,10 @@ import { useElementBounding } from '@vueuse/core' -import type { LGraphCanvas, Vector2 } from '@/lib/litegraph/src/litegraph' +import type { LGraphCanvas, Point } from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' + +let sharedConverter: ReturnType | null = + null /** * Convert between canvas and client positions @@ -14,7 +18,7 @@ export const useCanvasPositionConversion = ( ) => { const { left, top, update } = useElementBounding(canvasElement) - const clientPosToCanvasPos = (pos: Vector2): Vector2 => { + const clientPosToCanvasPos = (pos: Point): Point => { const { offset, scale } = lgCanvas.ds return [ (pos[0] - left.value) / scale - offset[0], @@ -22,7 +26,7 @@ export const useCanvasPositionConversion = ( ] } - const canvasPosToClientPos = (pos: Vector2): Vector2 => { + const canvasPosToClientPos = (pos: Point): Point => { const { offset, scale } = lgCanvas.ds return [ (pos[0] + offset[0]) * scale + left.value, @@ -36,3 +40,10 @@ export const useCanvasPositionConversion = ( update } } + +export function useSharedCanvasPositionConversion() { + if (sharedConverter) return sharedConverter + const lgCanvas = useCanvasStore().getCanvas() + sharedConverter = useCanvasPositionConversion(lgCanvas.canvas, lgCanvas) + return sharedConverter +} diff --git a/src/composables/useCanvasDrop.ts b/src/composables/useCanvasDrop.ts index 6d5557bde6..cb09207d1a 100644 --- a/src/composables/useCanvasDrop.ts +++ b/src/composables/useCanvasDrop.ts @@ -1,5 +1,6 @@ import { Ref } from 'vue' +import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion' import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop' import { LGraphNode } from '@/lib/litegraph/src/litegraph' import { LiteGraph } from '@/lib/litegraph/src/litegraph' @@ -27,16 +28,19 @@ export const useCanvasDrop = (canvasRef: Ref) => { if (dndData.type === 'tree-explorer-node') { const node = dndData.data as RenderedTreeExplorerNode + const conv = useSharedCanvasPositionConversion() + const basePos = conv.clientPosToCanvasPos([loc.clientX, loc.clientY]) + if (node.data instanceof ComfyNodeDefImpl) { const nodeDef = node.data - const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY]) + const pos = [...basePos] // Add an offset on y to make sure after adding the node, the cursor // is on the node (top left corner) pos[1] += LiteGraph.NODE_TITLE_HEIGHT litegraphService.addNodeOnGraph(nodeDef, { pos }) } else if (node.data instanceof ComfyModelDef) { const model = node.data - const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY]) + const pos = basePos const nodeAtPos = comfyApp.graph.getNodeOnPos(pos[0], pos[1]) let targetProvider: ModelNodeProvider | null = null let targetGraphNode: LGraphNode | null = null @@ -73,11 +77,7 @@ export const useCanvasDrop = (canvasRef: Ref) => { } } else if (node.data instanceof ComfyWorkflow) { const workflow = node.data - const position = comfyApp.clientPosToCanvasPos([ - loc.clientX, - loc.clientY - ]) - await workflowService.insertWorkflow(workflow, { position }) + await workflowService.insertWorkflow(workflow, { position: basePos }) } } } diff --git a/src/extensions/core/widgetInputs.ts b/src/extensions/core/widgetInputs.ts index 7f772aefcf..fdd7146a16 100644 --- a/src/extensions/core/widgetInputs.ts +++ b/src/extensions/core/widgetInputs.ts @@ -8,7 +8,7 @@ import type { INodeOutputSlot, ISlotType, LLink, - Vector2 + Point } from '@/lib/litegraph/src/litegraph' import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' @@ -557,7 +557,7 @@ app.registerExtension({ } ) - function isNodeAtPos(pos: Vector2) { + function isNodeAtPos(pos: Point) { for (const n of app.graph.nodes) { if (n.pos[0] === pos[0] && n.pos[1] === pos[1]) { return true diff --git a/src/platform/workflow/core/services/workflowService.ts b/src/platform/workflow/core/services/workflowService.ts index 943177986c..cc41f971e1 100644 --- a/src/platform/workflow/core/services/workflowService.ts +++ b/src/platform/workflow/core/services/workflowService.ts @@ -2,7 +2,7 @@ import { toRaw } from 'vue' import { t } from '@/i18n' import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph' -import type { SerialisableGraph, Vector2 } from '@/lib/litegraph/src/litegraph' +import type { Point, SerialisableGraph } from '@/lib/litegraph/src/litegraph' import { useSettingStore } from '@/platform/settings/settingStore' import { useToastStore } from '@/platform/updates/common/toastStore' import { @@ -346,7 +346,7 @@ export const useWorkflowService = () => { */ const insertWorkflow = async ( workflow: ComfyWorkflow, - options: { position?: Vector2 } = {} + options: { position?: Point } = {} ) => { const loadedWorkflow = await workflow.load() const workflowJSON = toRaw(loadedWorkflow.initialState) diff --git a/src/renderer/core/layout/injectionKeys.ts b/src/renderer/core/layout/injectionKeys.ts new file mode 100644 index 0000000000..dd6efda21b --- /dev/null +++ b/src/renderer/core/layout/injectionKeys.ts @@ -0,0 +1,49 @@ +import type { InjectionKey } from 'vue' + +import type { Point } from '@/renderer/core/layout/types' + +/** + * 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 { + /** Convert a screen-space point (CSS pixels) to canvas space. */ + screenToCanvas: (p: Point) => Point + /** Convert a canvas-space point to screen space (CSS pixels). */ + canvasToScreen: (p: Point) => Point + /** Current pan/zoom; `x`/`y` are offsets, `z` is scale. */ + camera?: { x: number; y: number; z: number } + /** + * Test whether a node's rectangle intersects the (expanded) viewport. + * Handy for viewport culling and lazy work. + * + * @param nodePos Top-left in canvas space `[x, y]` + * @param nodeSize Size in canvas units `[width, height]` + * @param viewport Screen-space viewport `{ width, height }` + * @param margin Optional fractional margin (e.g. `0.2` = 20%) + */ + isNodeInViewport?: ( + nodePos: ArrayLike, + nodeSize: ArrayLike, + viewport: { width: number; height: number }, + margin?: number + ) => boolean +} + +export const TransformStateKey: InjectionKey = + Symbol('transformState') diff --git a/src/renderer/core/layout/slots/useDomSlotRegistration.ts b/src/renderer/core/layout/slots/useDomSlotRegistration.ts deleted file mode 100644 index 94a1f09e59..0000000000 --- a/src/renderer/core/layout/slots/useDomSlotRegistration.ts +++ /dev/null @@ -1,229 +0,0 @@ -/** - * DOM-based slot registration with performance optimization - * - * Measures the actual DOM position of a Vue slot connector and registers it - * into the LayoutStore so hit-testing and link rendering use the true position. - * - * Performance strategy: - * - Cache slot offset relative to node (avoids DOM reads during drag) - * - No measurements during pan/zoom (camera transforms don't change canvas coords) - * - Batch DOM reads via requestAnimationFrame - * - Only remeasure on structural changes (resize, collapse, LOD) - */ -import { - type Ref, - type WatchStopHandle, - nextTick, - onMounted, - onUnmounted, - ref, - watch -} from 'vue' - -import { LiteGraph } from '@/lib/litegraph/src/litegraph' -import { layoutStore } from '@/renderer/core/layout/store/layoutStore' -import type { Point as LayoutPoint } from '@/renderer/core/layout/types' - -import { getSlotKey } from './slotIdentifier' - -export type TransformState = { - screenToCanvas: (p: LayoutPoint) => LayoutPoint -} - -// Shared RAF queue for batching measurements -const measureQueue = new Set<() => void>() -let rafId: number | null = null -// Track mounted components to prevent execution on unmounted ones -const mountedComponents = new WeakSet() - -function scheduleMeasurement(fn: () => void) { - measureQueue.add(fn) - if (rafId === null) { - rafId = requestAnimationFrame(() => { - rafId = null - const batch = Array.from(measureQueue) - measureQueue.clear() - batch.forEach((measure) => measure()) - }) - } -} - -const cleanupFunctions = new WeakMap< - Ref, - { - stopWatcher?: WatchStopHandle - handleResize?: () => void - } ->() - -interface SlotRegistrationOptions { - nodeId: string - slotIndex: number - isInput: boolean - element: Ref - transform?: TransformState -} - -export function useDomSlotRegistration(options: SlotRegistrationOptions) { - const { nodeId, slotIndex, isInput, element: elRef, transform } = options - - // Early return if no nodeId - if (!nodeId || nodeId === '') { - return { - remeasure: () => {} - } - } - const slotKey = getSlotKey(nodeId, slotIndex, isInput) - // Track if this component is mounted - const componentToken = {} - - // Cached offset from node position (avoids DOM reads during drag) - const cachedOffset = ref(null) - const lastMeasuredBounds = ref(null) - - // Measure DOM and cache offset (expensive, minimize calls) - const measureAndCacheOffset = () => { - // Skip if component was unmounted - if (!mountedComponents.has(componentToken)) return - - const el = elRef.value - if (!el || !transform?.screenToCanvas) return - - const rect = el.getBoundingClientRect() - - // Skip if bounds haven't changed significantly (within 0.5px) - if (lastMeasuredBounds.value) { - const prev = lastMeasuredBounds.value - if ( - Math.abs(rect.left - prev.left) < 0.5 && - Math.abs(rect.top - prev.top) < 0.5 && - Math.abs(rect.width - prev.width) < 0.5 && - Math.abs(rect.height - prev.height) < 0.5 - ) { - return // No significant change - skip update - } - } - - lastMeasuredBounds.value = rect - - // Center of the visual connector (dot) in screen coords - const centerScreen = { - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2 - } - const centerCanvas = transform.screenToCanvas(centerScreen) - - // Cache offset from node position for fast updates during drag - const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value - if (nodeLayout) { - cachedOffset.value = { - x: centerCanvas.x - nodeLayout.position.x, - y: centerCanvas.y - nodeLayout.position.y - } - } - - updateSlotPosition(centerCanvas) - } - - // Fast update using cached offset (no DOM read) - const updateFromCachedOffset = () => { - if (!cachedOffset.value) { - // No cached offset yet, need to measure - scheduleMeasurement(measureAndCacheOffset) - return - } - - const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value - if (!nodeLayout) { - return - } - - // Calculate absolute position from node position + cached offset - const centerCanvas = { - x: nodeLayout.position.x + cachedOffset.value.x, - y: nodeLayout.position.y + cachedOffset.value.y - } - - updateSlotPosition(centerCanvas) - } - - // Update slot position in layout store - const updateSlotPosition = (centerCanvas: LayoutPoint) => { - const size = LiteGraph.NODE_SLOT_HEIGHT - const half = size / 2 - - layoutStore.updateSlotLayout(slotKey, { - nodeId, - index: slotIndex, - type: isInput ? 'input' : 'output', - position: { x: centerCanvas.x, y: centerCanvas.y }, - bounds: { - x: centerCanvas.x - half, - y: centerCanvas.y - half, - width: size, - height: size - } - }) - } - - onMounted(async () => { - // Mark component as mounted - mountedComponents.add(componentToken) - - // Initial measure after mount - await nextTick() - measureAndCacheOffset() - - // Subscribe to node position changes for fast cached updates - const nodeRef = layoutStore.getNodeLayoutRef(nodeId) - - const stopWatcher = watch( - nodeRef, - (newLayout) => { - if (newLayout) { - // Node moved/resized - update using cached offset - updateFromCachedOffset() - } - }, - { immediate: false } - ) - - // Store cleanup functions without type assertions - const cleanup = cleanupFunctions.get(elRef) || {} - cleanup.stopWatcher = stopWatcher - - // Window resize - remeasure as viewport changed - const handleResize = () => { - scheduleMeasurement(measureAndCacheOffset) - } - window.addEventListener('resize', handleResize, { passive: true }) - cleanup.handleResize = handleResize - cleanupFunctions.set(elRef, cleanup) - }) - - onUnmounted(() => { - // Mark component as unmounted - mountedComponents.delete(componentToken) - - // Clean up watchers and listeners - const cleanup = cleanupFunctions.get(elRef) - if (cleanup) { - if (cleanup.stopWatcher) cleanup.stopWatcher() - if (cleanup.handleResize) { - window.removeEventListener('resize', cleanup.handleResize) - } - cleanupFunctions.delete(elRef) - } - - // Remove from layout store - layoutStore.deleteSlotLayout(slotKey) - - // Remove from measurement queue if pending - measureQueue.delete(measureAndCacheOffset) - }) - - return { - // Expose for forced remeasure on structural changes - remeasure: () => scheduleMeasurement(measureAndCacheOffset) - } -} diff --git a/src/renderer/core/layout/store/layoutStore.ts b/src/renderer/core/layout/store/layoutStore.ts index 6733440867..7daa4523cc 100644 --- a/src/renderer/core/layout/store/layoutStore.ts +++ b/src/renderer/core/layout/store/layoutStore.ts @@ -38,6 +38,10 @@ import { type RerouteLayout, type SlotLayout } from '@/renderer/core/layout/types' +import { + isBoundsEqual, + isPointEqual +} from '@/renderer/core/layout/utils/geometry' import { REROUTE_RADIUS, boundsIntersect, @@ -392,12 +396,8 @@ class LayoutStoreImpl implements LayoutStore { // Short-circuit if bounds and centerPos unchanged if ( existing && - existing.bounds.x === layout.bounds.x && - existing.bounds.y === layout.bounds.y && - existing.bounds.width === layout.bounds.width && - existing.bounds.height === layout.bounds.height && - existing.centerPos.x === layout.centerPos.x && - existing.centerPos.y === layout.centerPos.y + isBoundsEqual(existing.bounds, layout.bounds) && + isPointEqual(existing.centerPos, layout.centerPos) ) { // Only update path if provided (for hit detection) if (layout.path) { @@ -436,6 +436,13 @@ class LayoutStoreImpl implements LayoutStore { const existing = this.slotLayouts.get(key) if (existing) { + // Short-circuit if geometry is unchanged + if ( + isPointEqual(existing.position, layout.position) && + isBoundsEqual(existing.bounds, layout.bounds) + ) { + return + } // Update spatial index this.slotSpatialIndex.update(key, layout.bounds) } else { @@ -446,6 +453,34 @@ class LayoutStoreImpl implements LayoutStore { this.slotLayouts.set(key, layout) } + /** + * Batch update slot layouts and spatial index in one pass + */ + batchUpdateSlotLayouts( + updates: Array<{ key: string; layout: SlotLayout }> + ): void { + if (!updates.length) return + + // Update spatial index and map entries (skip unchanged) + for (const { key, layout } of updates) { + const existing = this.slotLayouts.get(key) + + if (existing) { + // Short-circuit if geometry is unchanged + if ( + isPointEqual(existing.position, layout.position) && + isBoundsEqual(existing.bounds, layout.bounds) + ) { + continue + } + this.slotSpatialIndex.update(key, layout.bounds) + } else { + this.slotSpatialIndex.insert(key, layout.bounds) + } + this.slotLayouts.set(key, layout) + } + } + /** * Delete slot layout data */ @@ -554,12 +589,8 @@ class LayoutStoreImpl implements LayoutStore { // Short-circuit if bounds and centerPos unchanged (prevents spatial index churn) if ( existing && - existing.bounds.x === layout.bounds.x && - existing.bounds.y === layout.bounds.y && - existing.bounds.width === layout.bounds.width && - existing.bounds.height === layout.bounds.height && - existing.centerPos.x === layout.centerPos.x && - existing.centerPos.y === layout.centerPos.y + isBoundsEqual(existing.bounds, layout.bounds) && + isPointEqual(existing.centerPos, layout.centerPos) ) { // Only update path if provided (for hit detection) if (layout.path) { @@ -968,9 +999,6 @@ class LayoutStoreImpl implements LayoutStore { // Hit detection queries can run before CRDT updates complete this.spatialIndex.update(operation.nodeId, newBounds) - // Update associated slot positions synchronously - this.updateNodeSlotPositions(operation.nodeId, operation.position) - // Then update CRDT ynode.set('position', operation.position) this.updateNodeBounds(ynode, operation.position, size) @@ -997,9 +1025,6 @@ class LayoutStoreImpl implements LayoutStore { // Hit detection queries can run before CRDT updates complete this.spatialIndex.update(operation.nodeId, newBounds) - // Update associated slot positions synchronously (size changes may affect slot positions) - this.updateNodeSlotPositions(operation.nodeId, position) - // Then update CRDT ynode.set('size', operation.size) this.updateNodeBounds(ynode, position, operation.size) @@ -1280,29 +1305,6 @@ class LayoutStoreImpl implements LayoutStore { } } - /** - * Update slot positions when a node moves - * TODO: This should be handled by the layout sync system (useSlotLayoutSync) - * rather than manually here. For now, we'll mark affected slots as needing recalculation. - */ - private updateNodeSlotPositions(nodeId: NodeId, _nodePosition: Point): void { - // Mark all slots for this node as potentially stale - // The layout sync system will recalculate positions on the next frame - const slotsToRemove: string[] = [] - - for (const [key, slotLayout] of this.slotLayouts) { - if (slotLayout.nodeId === nodeId) { - slotsToRemove.push(key) - } - } - - // Remove from spatial index so they'll be recalculated - for (const key of slotsToRemove) { - this.slotSpatialIndex.remove(key) - this.slotLayouts.delete(key) - } - } - // Helper methods private notifyChange(change: LayoutChange): void { diff --git a/src/renderer/core/layout/transform/TransformPane.vue b/src/renderer/core/layout/transform/TransformPane.vue index 0f88b177dc..70ece647ee 100644 --- a/src/renderer/core/layout/transform/TransformPane.vue +++ b/src/renderer/core/layout/transform/TransformPane.vue @@ -14,6 +14,7 @@ import { computed, provide } from 'vue' import { LGraphCanvas } from '@/lib/litegraph/src/litegraph' +import { TransformStateKey } from '@/renderer/core/layout/injectionKeys' import { useCanvasTransformSync } from '@/renderer/core/layout/transform/useCanvasTransformSync' import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling' import { useTransformState } from '@/renderer/core/layout/transform/useTransformState' @@ -39,7 +40,7 @@ const { isTransforming: isInteracting } = useTransformSettling(canvasElement, { trackPan: true }) -provide('transformState', { +provide(TransformStateKey, { camera, canvasToScreen, screenToCanvas, diff --git a/src/renderer/core/layout/types.ts b/src/renderer/core/layout/types.ts index 1dbd936d9c..ae2b761398 100644 --- a/src/renderer/core/layout/types.ts +++ b/src/renderer/core/layout/types.ts @@ -330,4 +330,8 @@ export interface LayoutStore { batchUpdateNodeBounds( updates: Array<{ nodeId: NodeId; bounds: Bounds }> ): void + + batchUpdateSlotLayouts( + updates: Array<{ key: string; layout: SlotLayout }> + ): void } diff --git a/src/renderer/core/layout/utils/geometry.ts b/src/renderer/core/layout/utils/geometry.ts new file mode 100644 index 0000000000..176cb0c98b --- /dev/null +++ b/src/renderer/core/layout/utils/geometry.ts @@ -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 +} diff --git a/src/renderer/extensions/vueNodes/components/InputOutputSlot.test.ts b/src/renderer/extensions/vueNodes/components/InputOutputSlot.test.ts index d10c9c3828..d1c886905b 100644 --- a/src/renderer/extensions/vueNodes/components/InputOutputSlot.test.ts +++ b/src/renderer/extensions/vueNodes/components/InputOutputSlot.test.ts @@ -4,15 +4,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createI18n } from 'vue-i18n' import enMessages from '@/locales/en/main.json' -import { useDomSlotRegistration } from '@/renderer/core/layout/slots/useDomSlotRegistration' +import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking' import InputSlot from './InputSlot.vue' import OutputSlot from './OutputSlot.vue' // Mock composable used by InputSlot/OutputSlot so we can assert call params -vi.mock('@/renderer/core/layout/slots/useDomSlotRegistration', () => ({ - useDomSlotRegistration: vi.fn(() => ({ remeasure: vi.fn() })) -})) +vi.mock( + '@/renderer/extensions/vueNodes/composables/useSlotElementTracking', + () => ({ + useSlotElementTracking: vi.fn(() => ({ stop: vi.fn() })) + }) +) type InputSlotProps = ComponentMountingOptions['props'] type OutputSlotProps = ComponentMountingOptions['props'] @@ -49,7 +52,7 @@ const mountOutputSlot = (props: OutputSlotProps) => describe('InputSlot/OutputSlot', () => { beforeEach(() => { - vi.mocked(useDomSlotRegistration).mockClear() + vi.mocked(useSlotElementTracking).mockClear() }) it('InputSlot registers with correct options', () => { @@ -59,11 +62,11 @@ describe('InputSlot/OutputSlot', () => { slotData: { name: 'A', type: 'any', boundingRect: [0, 0, 0, 0] } }) - expect(useDomSlotRegistration).toHaveBeenLastCalledWith( + expect(useSlotElementTracking).toHaveBeenLastCalledWith( expect.objectContaining({ nodeId: 'node-1', - slotIndex: 3, - isInput: true + index: 3, + type: 'input' }) ) }) @@ -75,11 +78,11 @@ describe('InputSlot/OutputSlot', () => { slotData: { name: 'B', type: 'any', boundingRect: [0, 0, 0, 0] } }) - expect(useDomSlotRegistration).toHaveBeenLastCalledWith( + expect(useSlotElementTracking).toHaveBeenLastCalledWith( expect.objectContaining({ nodeId: 'node-2', - slotIndex: 1, - isInput: false + index: 1, + type: 'output' }) ) }) diff --git a/src/renderer/extensions/vueNodes/components/InputSlot.vue b/src/renderer/extensions/vueNodes/components/InputSlot.vue index ea6d7db4e9..d7424b082e 100644 --- a/src/renderer/extensions/vueNodes/components/InputSlot.vue +++ b/src/renderer/extensions/vueNodes/components/InputSlot.vue @@ -32,7 +32,6 @@ import { type ComponentPublicInstance, computed, - inject, onErrorCaptured, ref, watchEffect @@ -41,11 +40,7 @@ import { import { useErrorHandling } from '@/composables/useErrorHandling' import { getSlotColor } from '@/constants/slotColors' import { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph' -// DOM-based slot registration for arbitrary positioning -import { - type TransformState, - useDomSlotRegistration -} from '@/renderer/core/layout/slots/useDomSlotRegistration' +import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking' import SlotConnectionDot from './SlotConnectionDot.vue' @@ -75,11 +70,6 @@ onErrorCaptured((error) => { // Get slot color based on type const slotColor = computed(() => getSlotColor(props.slotData.type)) -const transformState = inject( - 'transformState', - undefined -) - const connectionDotRef = ref | null>(null) @@ -92,11 +82,10 @@ watchEffect(() => { slotElRef.value = el || null }) -useDomSlotRegistration({ +useSlotElementTracking({ nodeId: props.nodeId ?? '', - slotIndex: props.index, - isInput: true, - element: slotElRef, - transform: transformState + index: props.index, + type: 'input', + element: slotElRef }) diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index dd8c7cd5b8..edb258d515 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -135,6 +135,7 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import { useErrorHandling } from '@/composables/useErrorHandling' import { LiteGraph } from '@/lib/litegraph/src/litegraph' import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys' +import { TransformStateKey } from '@/renderer/core/layout/injectionKeys' import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState' import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout' import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD' @@ -199,19 +200,7 @@ if (!selectedNodeIds) { } // Inject transform state for coordinate conversion -const transformState = inject('transformState') as - | { - camera: { z: number } - canvasToScreen: (point: { x: number; y: number }) => { - x: number - y: number - } - screenToCanvas: (point: { x: number; y: number }) => { - x: number - y: number - } - } - | undefined +const transformState = inject(TransformStateKey) // Computed selection state - only this node re-evaluates when its selection changes const isSelected = computed(() => { @@ -268,7 +257,7 @@ const { } = useNodeLayout(nodeData.id) onMounted(() => { - if (size && transformState) { + if (size && transformState?.camera) { const scale = transformState.camera.z const screenSize = { width: size.width * scale, diff --git a/src/renderer/extensions/vueNodes/components/OutputSlot.vue b/src/renderer/extensions/vueNodes/components/OutputSlot.vue index 51dcc1d227..ae8d4c7410 100644 --- a/src/renderer/extensions/vueNodes/components/OutputSlot.vue +++ b/src/renderer/extensions/vueNodes/components/OutputSlot.vue @@ -33,7 +33,6 @@ import { type ComponentPublicInstance, computed, - inject, onErrorCaptured, ref, watchEffect @@ -42,11 +41,7 @@ import { import { useErrorHandling } from '@/composables/useErrorHandling' import { getSlotColor } from '@/constants/slotColors' import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph' -// DOM-based slot registration for arbitrary positioning -import { - type TransformState, - useDomSlotRegistration -} from '@/renderer/core/layout/slots/useDomSlotRegistration' +import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking' import SlotConnectionDot from './SlotConnectionDot.vue' @@ -77,11 +72,6 @@ onErrorCaptured((error) => { // Get slot color based on type const slotColor = computed(() => getSlotColor(props.slotData.type)) -const transformState = inject( - 'transformState', - undefined -) - const connectionDotRef = ref | null>(null) @@ -94,11 +84,10 @@ watchEffect(() => { slotElRef.value = el || null }) -useDomSlotRegistration({ +useSlotElementTracking({ nodeId: props.nodeId ?? '', - slotIndex: props.index, - isInput: false, - element: slotElRef, - transform: transformState + index: props.index, + type: 'output', + element: slotElRef }) diff --git a/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts b/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts new file mode 100644 index 0000000000..24f9ee8073 --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts @@ -0,0 +1,220 @@ +/** + * 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() +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 +) { + 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 +}) { + const { nodeId, index, type, element } = options + const nodeSlotRegistryStore = useNodeSlotRegistryStore() + + onMounted(() => { + if (!nodeId) return + const stop = watch( + element, + (el) => { + if (!el) return + + // Ensure node entry + 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') + 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') + node.slots.delete(slotKey) + layoutStore.deleteSlotLayout(slotKey) + + // If node has no more slots, clean up + if (node.slots.size === 0) { + // Stop the node-level watcher when the last slot is gone + if (node.stopWatch) node.stopWatch() + nodeSlotRegistryStore.deleteNode(nodeId) + } + }) + + return { + requestSlotLayoutSync: () => scheduleSlotLayoutSync(nodeId) + } +} diff --git a/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts b/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts index c6be502857..4ce9f8e62c 100644 --- a/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts +++ b/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts @@ -10,9 +10,13 @@ */ import { getCurrentInstance, onMounted, onUnmounted } 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 */ @@ -54,8 +58,12 @@ const trackingConfigs: Map = new Map([ // Single ResizeObserver instance for all Vue elements const resizeObserver = new ResizeObserver((entries) => { - // Group updates by element type + // 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() + // Track nodes whose slots should be resynced after node size changes + const nodesNeedingSlotResync = new Set() for (const entry of entries) { if (!(entry.target instanceof HTMLElement)) continue @@ -76,30 +84,50 @@ const resizeObserver = new ResizeObserver((entries) => { if (!elementType || !elementId) continue - const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0] + // 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: rect.left, - y: rect.top, - width, - height: height + x: topLeftCanvas.x, + y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT, + width: Math.max(0, width), + height: Math.max(0, height - LiteGraph.NODE_TITLE_HEIGHT) } - if (!updatesByType.has(elementType)) { - updatesByType.set(elementType, []) + let updates = updatesByType.get(elementType) + if (!updates) { + updates = [] + updatesByType.set(elementType, updates) } - const updates = updatesByType.get(elementType) - if (updates) { - updates.push({ id: elementId, bounds }) + updates.push({ id: elementId, bounds }) + + // If this entry is a node, mark it for slot layout resync + if (elementType === 'node' && elementId) { + nodesNeedingSlotResync.add(elementId) } } - // Process updates by type + // Flush per-type for (const [type, updates] of updatesByType) { const config = trackingConfigs.get(type) - if (config && updates.length > 0) { - config.updateHandler(updates) + 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) } } }) @@ -134,11 +162,11 @@ export function useVueElementTracking( if (!(element instanceof HTMLElement) || !appIdentifier) return const config = trackingConfigs.get(trackingType) - if (config) { - // Set the appropriate data attribute - element.dataset[config.dataAttribute] = appIdentifier - resizeObserver.observe(element) - } + if (!config) return + + // Set the data attribute expected by the RO pipeline for this type + element.dataset[config.dataAttribute] = appIdentifier + resizeObserver.observe(element) }) onUnmounted(() => { @@ -146,10 +174,10 @@ export function useVueElementTracking( if (!(element instanceof HTMLElement)) return const config = trackingConfigs.get(trackingType) - if (config) { - // Remove the data attribute - delete element.dataset[config.dataAttribute] - resizeObserver.unobserve(element) - } + if (!config) return + + // Remove the data attribute and observer + delete element.dataset[config.dataAttribute] + resizeObserver.unobserve(element) }) } diff --git a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts index 995d83d6f2..18a085641e 100644 --- a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts +++ b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts @@ -7,6 +7,7 @@ import { computed, inject } from 'vue' import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys' +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' @@ -20,12 +21,7 @@ export function useNodeLayout(nodeId: string) { const mutations = useLayoutMutations() // Get transform utilities from TransformPane if available - const transformState = inject('transformState') as - | { - canvasToScreen: (point: Point) => Point - screenToCanvas: (point: Point) => Point - } - | undefined + const transformState = inject(TransformStateKey) // Get the customRef for this node (shared write access) const layoutRef = store.getNodeLayoutRef(nodeId) diff --git a/src/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore.ts b/src/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore.ts new file mode 100644 index 0000000000..c5e76d4b4c --- /dev/null +++ b/src/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore.ts @@ -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 + stopWatch?: () => void +} + +export const useNodeSlotRegistryStore = defineStore('nodeSlotRegistry', () => { + const registry = markRaw(new Map()) + + 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()) + } + registry.set(nodeId, node) + } + return node + } + + function deleteNode(nodeId: string) { + registry.delete(nodeId) + } + + function clear() { + registry.clear() + } + + return { + getNode, + ensureNode, + deleteNode, + clear + } +}) diff --git a/src/services/litegraphService.ts b/src/services/litegraphService.ts index 0579429da4..2149bb3787 100644 --- a/src/services/litegraphService.ts +++ b/src/services/litegraphService.ts @@ -12,10 +12,10 @@ import { LGraphEventMode, LGraphNode, LiteGraph, + type Point, RenderShape, type Subgraph, SubgraphNode, - type Vector2, createBounds } from '@/lib/litegraph/src/litegraph' import type { @@ -994,7 +994,7 @@ export const useLitegraphService = () => { return node } - function getCanvasCenter(): Vector2 { + function getCanvasCenter(): Point { const dpi = Math.max(window.devicePixelRatio ?? 1, 1) const [x, y, w, h] = app.canvas.ds.visible_area return [x + w / dpi / 2, y + h / dpi / 2] diff --git a/tests-ui/tests/utils/migration/migrateReroute.test.ts b/tests-ui/tests/utils/migration/migrateReroute.test.ts index 767eb0a2c9..ce7b62a2e7 100644 --- a/tests-ui/tests/utils/migration/migrateReroute.test.ts +++ b/tests-ui/tests/utils/migration/migrateReroute.test.ts @@ -19,7 +19,7 @@ describe('migrateReroute', () => { 'single_connected.json', 'floating.json', 'floating_branch.json' - ])('should correctly migrate %s', (fileName) => { + ])('should correctly migrate %s', async (fileName) => { // Load the legacy workflow const legacyWorkflow = loadWorkflow( `workflows/reroute/legacy/${fileName}` @@ -29,9 +29,9 @@ describe('migrateReroute', () => { const migratedWorkflow = migrateLegacyRerouteNodes(legacyWorkflow) // Compare with snapshot - expect(JSON.stringify(migratedWorkflow, null, 2)).toMatchFileSnapshot( - `workflows/reroute/native/${fileName}` - ) + await expect( + JSON.stringify(migratedWorkflow, null, 2) + ).toMatchFileSnapshot(`workflows/reroute/native/${fileName}`) }) }) })