mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-10 10:00:08 +00:00
Refactor vue slot tracking (#5463)
* add dom element resize observer registry for vue node components * Update src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts Co-authored-by: AustinMroz <austin@comfy.org> * refactor(vue-nodes): typed TransformState InjectionKey, safer ResizeObserver sizing, centralized slot tracking, and small readability updates * chore: make TransformState interface non-exported to satisfy knip pre-push * Revert "chore: make TransformState interface non-exported to satisfy knip pre-push" This reverts commit110ecf31da. * Revert "refactor(vue-nodes): typed TransformState InjectionKey, safer ResizeObserver sizing, centralized slot tracking, and small readability updates" This reverts commit428752619c. * [refactor] Improve resize tracking composable documentation and test utilities - Rename parameters in useVueElementTracking for clarity (appIdentifier, trackingType) - Add comprehensive docstring with examples to prevent DOM attribute confusion - Extract mountLGraphNode test utility to eliminate repetitive mock setup - Add technical implementation notes documenting optimization decisions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * remove typo comment * convert to functional bounds collection * remove inline import * add interfaces for bounds mutations * remove change log * fix bounds collection when vue nodes turned off * fix title offset on y * move from resize observer to selection toolbox bounds * refactor(vue-nodes): typed TransformState InjectionKey, safer ResizeObserver sizing, centralized slot tracking, and small readability updates * Fix conversion * Readd padding * revert churn reducings from layoutStore.ts * Rely on RO for resize, and batch * Improve churn * Cache canvas offset * rename from measure * remove unused * address review comments * Update legacy injection * nit * Split into store * nit * perf improvement --------- Co-authored-by: bymyself <cbyrne@comfy.org> Co-authored-by: AustinMroz <austin@comfy.org> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof useCanvasPositionConversion> | 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
|
||||
}
|
||||
|
||||
@@ -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<HTMLCanvasElement>) => {
|
||||
|
||||
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<HTMLCanvasElement>) => {
|
||||
}
|
||||
} 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
49
src/renderer/core/layout/injectionKeys.ts
Normal file
49
src/renderer/core/layout/injectionKeys.ts
Normal file
@@ -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<number>,
|
||||
nodeSize: ArrayLike<number>,
|
||||
viewport: { width: number; height: number },
|
||||
margin?: number
|
||||
) => boolean
|
||||
}
|
||||
|
||||
export const TransformStateKey: InjectionKey<TransformState> =
|
||||
Symbol('transformState')
|
||||
@@ -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<object>()
|
||||
|
||||
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<HTMLElement | null>,
|
||||
{
|
||||
stopWatcher?: WatchStopHandle
|
||||
handleResize?: () => void
|
||||
}
|
||||
>()
|
||||
|
||||
interface SlotRegistrationOptions {
|
||||
nodeId: string
|
||||
slotIndex: number
|
||||
isInput: boolean
|
||||
element: Ref<HTMLElement | null>
|
||||
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<LayoutPoint | null>(null)
|
||||
const lastMeasuredBounds = ref<DOMRect | null>(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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -330,4 +330,8 @@ export interface LayoutStore {
|
||||
batchUpdateNodeBounds(
|
||||
updates: Array<{ nodeId: NodeId; bounds: Bounds }>
|
||||
): void
|
||||
|
||||
batchUpdateSlotLayouts(
|
||||
updates: Array<{ key: string; layout: SlotLayout }>
|
||||
): void
|
||||
}
|
||||
|
||||
15
src/renderer/core/layout/utils/geometry.ts
Normal file
15
src/renderer/core/layout/utils/geometry.ts
Normal file
@@ -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
|
||||
}
|
||||
@@ -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<typeof InputSlot>['props']
|
||||
type OutputSlotProps = ComponentMountingOptions<typeof OutputSlot>['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'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>(
|
||||
'transformState',
|
||||
undefined
|
||||
)
|
||||
|
||||
const connectionDotRef = ref<ComponentPublicInstance<{
|
||||
slotElRef: HTMLElement | undefined
|
||||
}> | 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
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -147,6 +147,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'
|
||||
@@ -212,19 +213,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(() => {
|
||||
@@ -281,7 +270,7 @@ const {
|
||||
} = useNodeLayout(nodeData.id)
|
||||
|
||||
onMounted(() => {
|
||||
if (size && transformState) {
|
||||
if (size && transformState?.camera) {
|
||||
const scale = transformState.camera.z
|
||||
const screenSize = {
|
||||
width: size.width * scale,
|
||||
|
||||
@@ -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>(
|
||||
'transformState',
|
||||
undefined
|
||||
)
|
||||
|
||||
const connectionDotRef = ref<ComponentPublicInstance<{
|
||||
slotElRef: HTMLElement | undefined
|
||||
}> | 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
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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<string>()
|
||||
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<typeof useSharedCanvasPositionConversion>
|
||||
) {
|
||||
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<HTMLElement | null>
|
||||
}) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<string, ElementTrackingConfig> = 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<string, ElementBoundsUpdate[]>()
|
||||
// Track nodes whose slots should be resynced after node size changes
|
||||
const nodesNeedingSlotResync = new Set<string>()
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, SlotEntry>
|
||||
stopWatch?: () => void
|
||||
}
|
||||
|
||||
export const useNodeSlotRegistryStore = defineStore('nodeSlotRegistry', () => {
|
||||
const registry = markRaw(new Map<string, NodeEntry>())
|
||||
|
||||
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<string, SlotEntry>())
|
||||
}
|
||||
registry.set(nodeId, node)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function deleteNode(nodeId: string) {
|
||||
registry.delete(nodeId)
|
||||
}
|
||||
|
||||
function clear() {
|
||||
registry.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
getNode,
|
||||
ensureNode,
|
||||
deleteNode,
|
||||
clear
|
||||
}
|
||||
})
|
||||
@@ -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]
|
||||
|
||||
@@ -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}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user