@@ -65,8 +69,10 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
+import PackUpdateButton from '@/components/dialog/content/manager/button/PackUpdateButton.vue'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
+import { useUpdateAvailableNodes } from '@/composables/nodePack/useUpdateAvailableNodes'
import {
type SearchOption,
SortableAlgoliaField
@@ -83,6 +89,7 @@ const { searchResults, sortOptions } = defineProps<{
suggestions?: QuerySuggestion[]
sortOptions?: SortableField[]
isMissingTab?: boolean
+ isUpdateAvailableTab?: boolean
}>()
const searchQuery = defineModel('searchQuery')
@@ -96,6 +103,13 @@ const { t } = useI18n()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error } = useMissingNodes()
+// Use the composable to get update available nodes
+const {
+ hasUpdateAvailable,
+ enabledUpdateAvailableNodePacks,
+ hasDisabledUpdatePacks
+} = useUpdateAvailableNodes()
+
const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length
)
diff --git a/src/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue b/src/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue
index f91035b3c..e9ded729f 100644
--- a/src/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue
+++ b/src/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue
@@ -19,6 +19,7 @@
diff --git a/src/components/dialog/content/setting/CreditsPanel.vue b/src/components/dialog/content/setting/CreditsPanel.vue
index 4d6486a98..374dfbe14 100644
--- a/src/components/dialog/content/setting/CreditsPanel.vue
+++ b/src/components/dialog/content/setting/CreditsPanel.vue
@@ -54,7 +54,7 @@
-
+
@@ -112,12 +112,12 @@ import Divider from 'primevue/divider'
import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel'
import { computed, ref, watch } from 'vue'
-import { useI18n } from 'vue-i18n'
import UserCredit from '@/components/common/UserCredit.vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useDialogService } from '@/services/dialogService'
+import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
@@ -128,10 +128,10 @@ interface CreditHistoryItemData {
isPositive: boolean
}
-const { t } = useI18n()
const dialogService = useDialogService()
const authStore = useFirebaseAuthStore()
const authActions = useFirebaseAuthActions()
+const commandStore = useCommandStore()
const loading = computed(() => authStore.loading)
const balanceLoading = computed(() => authStore.isFetchingBalance)
@@ -160,15 +160,8 @@ const handleCreditsHistoryClick = async () => {
await authActions.accessBillingPortal()
}
-const handleMessageSupport = () => {
- dialogService.showIssueReportDialog({
- title: t('issueReport.contactSupportTitle'),
- subtitle: t('issueReport.contactSupportDescription'),
- panelProps: {
- errorType: 'BillingSupport',
- defaultFields: ['Workflow', 'Logs', 'SystemStats', 'Settings']
- }
- })
+const handleMessageSupport = async () => {
+ await commandStore.execute('Comfy.ContactSupport')
}
const handleFaqClick = () => {
diff --git a/src/components/dialog/content/setting/KeybindingPanel.vue b/src/components/dialog/content/setting/KeybindingPanel.vue
index 97a373421..dbe61f089 100644
--- a/src/components/dialog/content/setting/KeybindingPanel.vue
+++ b/src/components/dialog/content/setting/KeybindingPanel.vue
@@ -295,6 +295,8 @@ async function resetAllKeybindings() {
diff --git a/src/renderer/core/layout/__tests__/TransformPane.spec.ts b/src/renderer/core/layout/__tests__/TransformPane.spec.ts
new file mode 100644
index 000000000..f6fe7126c
--- /dev/null
+++ b/src/renderer/core/layout/__tests__/TransformPane.spec.ts
@@ -0,0 +1,350 @@
+import { mount } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick, ref } from 'vue'
+
+import TransformPane from '../TransformPane.vue'
+
+// Mock the transform state composable
+const mockTransformState = {
+ camera: ref({ x: 0, y: 0, z: 1 }),
+ transformStyle: ref({
+ transform: 'scale(1) translate(0px, 0px)',
+ transformOrigin: '0 0'
+ }),
+ syncWithCanvas: vi.fn(),
+ canvasToScreen: vi.fn(),
+ screenToCanvas: vi.fn(),
+ isNodeInViewport: vi.fn()
+}
+
+vi.mock('@/renderer/core/spatial/useTransformState', () => ({
+ useTransformState: () => mockTransformState
+}))
+
+// Mock requestAnimationFrame/cancelAnimationFrame
+global.requestAnimationFrame = vi.fn((cb) => {
+ setTimeout(cb, 16)
+ return 1
+})
+global.cancelAnimationFrame = vi.fn()
+
+describe('TransformPane', () => {
+ let wrapper: ReturnType
+ let mockCanvas: any
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ // Create mock canvas with LiteGraph interface
+ mockCanvas = {
+ canvas: {
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn()
+ },
+ ds: {
+ offset: [0, 0],
+ scale: 1
+ }
+ }
+
+ // Reset mock transform state
+ mockTransformState.camera.value = { x: 0, y: 0, z: 1 }
+ mockTransformState.transformStyle.value = {
+ transform: 'scale(1) translate(0px, 0px)',
+ transformOrigin: '0 0'
+ }
+ })
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.unmount()
+ }
+ })
+
+ describe('component mounting', () => {
+ it('should mount successfully with minimal props', () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ expect(wrapper.exists()).toBe(true)
+ expect(wrapper.find('.transform-pane').exists()).toBe(true)
+ })
+
+ it('should apply transform style from composable', () => {
+ mockTransformState.transformStyle.value = {
+ transform: 'scale(2) translate(100px, 50px)',
+ transformOrigin: '0 0'
+ }
+
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ const transformPane = wrapper.find('.transform-pane')
+ const style = transformPane.attributes('style')
+ expect(style).toContain('transform: scale(2) translate(100px, 50px)')
+ })
+
+ it('should render slot content', () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ },
+ slots: {
+ default: 'Test Node
'
+ }
+ })
+
+ expect(wrapper.find('.test-content').exists()).toBe(true)
+ expect(wrapper.find('.test-content').text()).toBe('Test Node')
+ })
+ })
+
+ describe('RAF synchronization', () => {
+ it('should start RAF sync on mount', async () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ await nextTick()
+
+ // Should emit RAF status change to true
+ expect(wrapper.emitted('rafStatusChange')).toBeTruthy()
+ expect(wrapper.emitted('rafStatusChange')?.[0]).toEqual([true])
+ })
+
+ it('should call syncWithCanvas during RAF updates', async () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ await nextTick()
+
+ // Allow RAF to execute
+ await new Promise((resolve) => setTimeout(resolve, 20))
+
+ expect(mockTransformState.syncWithCanvas).toHaveBeenCalledWith(mockCanvas)
+ })
+
+ it('should emit transform update timing', async () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ await nextTick()
+
+ // Allow RAF to execute
+ await new Promise((resolve) => setTimeout(resolve, 20))
+
+ expect(wrapper.emitted('transformUpdate')).toBeTruthy()
+ const updateEvent = wrapper.emitted('transformUpdate')?.[0]
+ expect(typeof updateEvent?.[0]).toBe('number')
+ expect(updateEvent?.[0]).toBeGreaterThanOrEqual(0)
+ })
+
+ it('should stop RAF sync on unmount', async () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ await nextTick()
+ wrapper.unmount()
+
+ expect(wrapper.emitted('rafStatusChange')).toBeTruthy()
+ const events = wrapper.emitted('rafStatusChange') as any[]
+ expect(events[events.length - 1]).toEqual([false])
+ expect(global.cancelAnimationFrame).toHaveBeenCalled()
+ })
+ })
+
+ describe('canvas event listeners', () => {
+ it('should add event listeners to canvas on mount', async () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ await nextTick()
+
+ expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
+ 'wheel',
+ expect.any(Function),
+ expect.any(Object)
+ )
+ expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
+ 'pointerdown',
+ expect.any(Function),
+ expect.any(Object)
+ )
+ expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
+ 'pointerup',
+ expect.any(Function),
+ expect.any(Object)
+ )
+ expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
+ 'pointercancel',
+ expect.any(Function),
+ expect.any(Object)
+ )
+ })
+
+ it('should remove event listeners on unmount', async () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ await nextTick()
+ wrapper.unmount()
+
+ expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
+ 'wheel',
+ expect.any(Function),
+ expect.any(Object)
+ )
+ expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
+ 'pointerdown',
+ expect.any(Function),
+ expect.any(Object)
+ )
+ expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
+ 'pointerup',
+ expect.any(Function),
+ expect.any(Object)
+ )
+ expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
+ 'pointercancel',
+ expect.any(Function),
+ expect.any(Object)
+ )
+ })
+ })
+
+ describe('interaction state management', () => {
+ it('should apply interacting class during interactions', async () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ // Simulate interaction start by checking internal state
+ // Note: This tests the CSS class application logic
+ const transformPane = wrapper.find('.transform-pane')
+
+ // Initially should not have interacting class
+ expect(transformPane.classes()).not.toContain(
+ 'transform-pane--interacting'
+ )
+ })
+
+ it('should handle pointer events for node delegation', async () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ const transformPane = wrapper.find('.transform-pane')
+
+ // Simulate pointer down - we can't test the exact delegation logic
+ // in unit tests due to vue-test-utils limitations, but we can verify
+ // the event handler is set up correctly
+ await transformPane.trigger('pointerdown')
+
+ // The test passes if no errors are thrown during event handling
+ expect(transformPane.exists()).toBe(true)
+ })
+ })
+
+ describe('transform state integration', () => {
+ it('should provide transform utilities to child components', () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ // The component should provide transform state via Vue's provide/inject
+ // This is tested indirectly through the composable integration
+ expect(mockTransformState.syncWithCanvas).toBeDefined()
+ expect(mockTransformState.canvasToScreen).toBeDefined()
+ expect(mockTransformState.screenToCanvas).toBeDefined()
+ })
+ })
+
+ describe('error handling', () => {
+ it('should handle null canvas gracefully', () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: undefined
+ }
+ })
+
+ expect(wrapper.exists()).toBe(true)
+ expect(wrapper.find('.transform-pane').exists()).toBe(true)
+ })
+
+ it('should handle missing canvas properties', () => {
+ const incompleteCanvas = {} as any
+
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: incompleteCanvas
+ }
+ })
+
+ expect(wrapper.exists()).toBe(true)
+ // Should not throw errors during mount
+ })
+ })
+
+ describe('performance optimizations', () => {
+ it('should use contain CSS property for layout optimization', () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ const transformPane = wrapper.find('.transform-pane')
+
+ // This test verifies the CSS contains the performance optimization
+ // Note: In JSDOM, computed styles might not reflect all CSS properties
+ expect(transformPane.element.className).toContain('transform-pane')
+ })
+
+ it('should disable pointer events on container but allow on children', () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ },
+ slots: {
+ default: 'Test Node
'
+ }
+ })
+
+ const transformPane = wrapper.find('.transform-pane')
+
+ // The CSS should handle pointer events optimization
+ // This is primarily a CSS concern, but we verify the structure
+ expect(transformPane.exists()).toBe(true)
+ expect(wrapper.find('[data-node-id="test"]').exists()).toBe(true)
+ })
+ })
+})
diff --git a/src/renderer/core/layout/constants.ts b/src/renderer/core/layout/constants.ts
new file mode 100644
index 000000000..cc1de914e
--- /dev/null
+++ b/src/renderer/core/layout/constants.ts
@@ -0,0 +1,50 @@
+/**
+ * Layout System Constants
+ *
+ * Centralized configuration values for the layout system.
+ * These values control spatial indexing, performance, and behavior.
+ */
+import { LayoutSource } from '@/renderer/core/layout/types'
+
+/**
+ * QuadTree configuration for spatial indexing
+ */
+export const QUADTREE_CONFIG = {
+ /** Default bounds for the QuadTree - covers a large canvas area */
+ DEFAULT_BOUNDS: {
+ x: -10000,
+ y: -10000,
+ width: 20000,
+ height: 20000
+ },
+ /** Maximum tree depth to prevent excessive subdivision */
+ MAX_DEPTH: 6,
+ /** Maximum items per node before subdivision */
+ MAX_ITEMS_PER_NODE: 4
+} as const
+
+/**
+ * Performance and optimization settings
+ */
+export const PERFORMANCE_CONFIG = {
+ /** RAF-based change detection interval (roughly 60fps) */
+ CHANGE_DETECTION_INTERVAL: 16,
+ /** Spatial query cache TTL in milliseconds */
+ SPATIAL_CACHE_TTL: 1000,
+ /** Maximum cache size for spatial queries */
+ SPATIAL_CACHE_MAX_SIZE: 100,
+ /** Batch update delay in milliseconds */
+ BATCH_UPDATE_DELAY: 4
+} as const
+
+/**
+ * Actor and source identifiers
+ */
+export const ACTOR_CONFIG = {
+ /** Prefix for auto-generated actor IDs */
+ USER_PREFIX: 'user-',
+ /** Length of random suffix for actor IDs */
+ ID_LENGTH: 9,
+ /** Default source when not specified */
+ DEFAULT_SOURCE: LayoutSource.External
+} as const
diff --git a/src/renderer/core/layout/operations/layoutMutations.ts b/src/renderer/core/layout/operations/layoutMutations.ts
new file mode 100644
index 000000000..0c656899d
--- /dev/null
+++ b/src/renderer/core/layout/operations/layoutMutations.ts
@@ -0,0 +1,340 @@
+/**
+ * Layout Mutations - Simplified Direct Operations
+ *
+ * Provides a clean API for layout operations that are CRDT-ready.
+ * Operations are synchronous and applied directly to the store.
+ */
+import log from 'loglevel'
+
+import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
+import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
+import {
+ LayoutSource,
+ type LinkId,
+ type NodeLayout,
+ type Point,
+ type RerouteId,
+ type Size
+} from '@/renderer/core/layout/types'
+
+const logger = log.getLogger('LayoutMutations')
+
+interface LayoutMutations {
+ // Single node operations (synchronous, CRDT-ready)
+ moveNode(nodeId: NodeId, position: Point): void
+ resizeNode(nodeId: NodeId, size: Size): void
+ setNodeZIndex(nodeId: NodeId, zIndex: number): void
+
+ // Node lifecycle operations
+ createNode(nodeId: NodeId, layout: Partial): void
+ deleteNode(nodeId: NodeId): void
+
+ // Link operations
+ createLink(
+ linkId: LinkId,
+ sourceNodeId: NodeId,
+ sourceSlot: number,
+ targetNodeId: NodeId,
+ targetSlot: number
+ ): void
+ deleteLink(linkId: LinkId): void
+
+ // Reroute operations
+ createReroute(
+ rerouteId: RerouteId,
+ position: Point,
+ parentId?: LinkId,
+ linkIds?: LinkId[]
+ ): void
+ deleteReroute(rerouteId: RerouteId): void
+ moveReroute(
+ rerouteId: RerouteId,
+ position: Point,
+ previousPosition: Point
+ ): void
+
+ // Stacking operations
+ bringNodeToFront(nodeId: NodeId): void
+
+ // Source tracking
+ setSource(source: LayoutSource): void
+ setActor(actor: string): void
+}
+
+/**
+ * Composable for accessing layout mutations with clean destructuring API
+ */
+export function useLayoutMutations(): LayoutMutations {
+ /**
+ * Set the current mutation source
+ */
+ const setSource = (source: LayoutSource): void => {
+ layoutStore.setSource(source)
+ }
+
+ /**
+ * Set the current actor (for CRDT)
+ */
+ const setActor = (actor: string): void => {
+ layoutStore.setActor(actor)
+ }
+
+ /**
+ * Move a node to a new position
+ */
+ const moveNode = (nodeId: NodeId, position: Point): void => {
+ const normalizedNodeId = String(nodeId)
+ const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
+ if (!existing) return
+
+ layoutStore.applyOperation({
+ type: 'moveNode',
+ entity: 'node',
+ nodeId: normalizedNodeId,
+ position,
+ previousPosition: existing.position,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Resize a node
+ */
+ const resizeNode = (nodeId: NodeId, size: Size): void => {
+ const normalizedNodeId = String(nodeId)
+ const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
+ if (!existing) return
+
+ layoutStore.applyOperation({
+ type: 'resizeNode',
+ entity: 'node',
+ nodeId: normalizedNodeId,
+ size,
+ previousSize: existing.size,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Set node z-index
+ */
+ const setNodeZIndex = (nodeId: NodeId, zIndex: number): void => {
+ const normalizedNodeId = String(nodeId)
+ const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
+ if (!existing) return
+
+ layoutStore.applyOperation({
+ type: 'setNodeZIndex',
+ entity: 'node',
+ nodeId: normalizedNodeId,
+ zIndex,
+ previousZIndex: existing.zIndex,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Create a new node
+ */
+ const createNode = (nodeId: NodeId, layout: Partial): void => {
+ const normalizedNodeId = String(nodeId)
+ const fullLayout: NodeLayout = {
+ id: normalizedNodeId,
+ position: layout.position ?? { x: 0, y: 0 },
+ size: layout.size ?? { width: 200, height: 100 },
+ zIndex: layout.zIndex ?? 0,
+ visible: layout.visible ?? true,
+ bounds: {
+ x: layout.position?.x ?? 0,
+ y: layout.position?.y ?? 0,
+ width: layout.size?.width ?? 200,
+ height: layout.size?.height ?? 100
+ }
+ }
+
+ layoutStore.applyOperation({
+ type: 'createNode',
+ entity: 'node',
+ nodeId: normalizedNodeId,
+ layout: fullLayout,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Delete a node
+ */
+ const deleteNode = (nodeId: NodeId): void => {
+ const normalizedNodeId = String(nodeId)
+ const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
+ if (!existing) return
+
+ layoutStore.applyOperation({
+ type: 'deleteNode',
+ entity: 'node',
+ nodeId: normalizedNodeId,
+ previousLayout: existing,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Bring a node to the front (highest z-index)
+ */
+ const bringNodeToFront = (nodeId: NodeId): void => {
+ // Get all nodes to find the highest z-index
+ const allNodes = layoutStore.getAllNodes().value
+ let maxZIndex = 0
+
+ for (const [, layout] of allNodes) {
+ if (layout.zIndex > maxZIndex) {
+ maxZIndex = layout.zIndex
+ }
+ }
+
+ // Set this node's z-index to be one higher than the current max
+ setNodeZIndex(nodeId, maxZIndex + 1)
+ }
+
+ /**
+ * Create a new link
+ */
+ const createLink = (
+ linkId: LinkId,
+ sourceNodeId: NodeId,
+ sourceSlot: number,
+ targetNodeId: NodeId,
+ targetSlot: number
+ ): void => {
+ // Normalize node IDs to strings for layout store consistency
+ const normalizedSourceNodeId = String(sourceNodeId)
+ const normalizedTargetNodeId = String(targetNodeId)
+
+ logger.debug('Creating link:', {
+ linkId,
+ from: `${normalizedSourceNodeId}[${sourceSlot}]`,
+ to: `${normalizedTargetNodeId}[${targetSlot}]`
+ })
+ layoutStore.applyOperation({
+ type: 'createLink',
+ entity: 'link',
+ linkId,
+ sourceNodeId: normalizedSourceNodeId,
+ sourceSlot,
+ targetNodeId: normalizedTargetNodeId,
+ targetSlot,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Delete a link
+ */
+ const deleteLink = (linkId: LinkId): void => {
+ logger.debug('Deleting link:', linkId)
+ layoutStore.applyOperation({
+ type: 'deleteLink',
+ entity: 'link',
+ linkId,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Create a new reroute
+ */
+ const createReroute = (
+ rerouteId: RerouteId,
+ position: Point,
+ parentId?: LinkId,
+ linkIds: LinkId[] = []
+ ): void => {
+ logger.debug('Creating reroute:', {
+ rerouteId,
+ position,
+ parentId,
+ linkCount: linkIds.length
+ })
+ layoutStore.applyOperation({
+ type: 'createReroute',
+ entity: 'reroute',
+ rerouteId,
+ position,
+ parentId,
+ linkIds,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Delete a reroute
+ */
+ const deleteReroute = (rerouteId: RerouteId): void => {
+ logger.debug('Deleting reroute:', rerouteId)
+ layoutStore.applyOperation({
+ type: 'deleteReroute',
+ entity: 'reroute',
+ rerouteId,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Move a reroute
+ */
+ const moveReroute = (
+ rerouteId: RerouteId,
+ position: Point,
+ previousPosition: Point
+ ): void => {
+ logger.debug('Moving reroute:', {
+ rerouteId,
+ from: previousPosition,
+ to: position
+ })
+ layoutStore.applyOperation({
+ type: 'moveReroute',
+ entity: 'reroute',
+ rerouteId,
+ position,
+ previousPosition,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ return {
+ setSource,
+ setActor,
+ moveNode,
+ resizeNode,
+ setNodeZIndex,
+ createNode,
+ deleteNode,
+ bringNodeToFront,
+ createLink,
+ deleteLink,
+ createReroute,
+ deleteReroute,
+ moveReroute
+ }
+}
diff --git a/src/renderer/core/layout/slots/register.ts b/src/renderer/core/layout/slots/register.ts
new file mode 100644
index 000000000..afe7242ee
--- /dev/null
+++ b/src/renderer/core/layout/slots/register.ts
@@ -0,0 +1,75 @@
+/**
+ * Slot Registration
+ *
+ * Handles registration of slot layouts with the layout store for hit testing.
+ * This module manages the state mutation side of slot layout management,
+ * while pure calculations are handled separately in SlotCalculations.ts.
+ */
+import type { Point } from '@/lib/litegraph/src/interfaces'
+import { LiteGraph } from '@/lib/litegraph/src/litegraph'
+import {
+ type SlotPositionContext,
+ calculateInputSlotPos,
+ calculateOutputSlotPos
+} from '@/renderer/core/canvas/litegraph/slotCalculations'
+import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
+import type { SlotLayout } from '@/renderer/core/layout/types'
+
+import { getSlotKey } from './slotIdentifier'
+
+/**
+ * Register slot layout with the layout store for hit testing
+ * @param nodeId The node ID
+ * @param slotIndex The slot index
+ * @param isInput Whether this is an input slot
+ * @param position The slot position in graph coordinates
+ */
+function registerSlotLayout(
+ nodeId: string,
+ slotIndex: number,
+ isInput: boolean,
+ position: Point
+): void {
+ const slotKey = getSlotKey(nodeId, slotIndex, isInput)
+
+ // Calculate bounds for the slot using LiteGraph's standard slot height
+ const slotSize = LiteGraph.NODE_SLOT_HEIGHT
+ const halfSize = slotSize / 2
+
+ const slotLayout: SlotLayout = {
+ nodeId,
+ index: slotIndex,
+ type: isInput ? 'input' : 'output',
+ position: { x: position[0], y: position[1] },
+ bounds: {
+ x: position[0] - halfSize,
+ y: position[1] - halfSize,
+ width: slotSize,
+ height: slotSize
+ }
+ }
+
+ layoutStore.updateSlotLayout(slotKey, slotLayout)
+}
+
+/**
+ * Register all slots for a node
+ * @param nodeId The node ID
+ * @param context The slot position context
+ */
+export function registerNodeSlots(
+ nodeId: string,
+ context: SlotPositionContext
+): void {
+ // Register input slots
+ context.inputs.forEach((_, index) => {
+ const position = calculateInputSlotPos(context, index)
+ registerSlotLayout(nodeId, index, true, position)
+ })
+
+ // Register output slots
+ context.outputs.forEach((_, index) => {
+ const position = calculateOutputSlotPos(context, index)
+ registerSlotLayout(nodeId, index, false, position)
+ })
+}
diff --git a/src/renderer/core/layout/slots/slotIdentifier.ts b/src/renderer/core/layout/slots/slotIdentifier.ts
new file mode 100644
index 000000000..2600405e9
--- /dev/null
+++ b/src/renderer/core/layout/slots/slotIdentifier.ts
@@ -0,0 +1,40 @@
+/**
+ * Slot identifier utilities for consistent slot key generation and parsing
+ *
+ * Provides a centralized interface for slot identification across the layout system
+ *
+ * @TODO Replace this concatenated string with root cause fix
+ */
+
+interface SlotIdentifier {
+ nodeId: string
+ index: number
+ isInput: boolean
+}
+
+/**
+ * Generate a unique key for a slot
+ * Format: "{nodeId}-{in|out}-{index}"
+ */
+export function getSlotKey(identifier: SlotIdentifier): string
+export function getSlotKey(
+ nodeId: string,
+ index: number,
+ isInput: boolean
+): string
+export function getSlotKey(
+ nodeIdOrIdentifier: string | SlotIdentifier,
+ index?: number,
+ isInput?: boolean
+): string {
+ if (typeof nodeIdOrIdentifier === 'object') {
+ const { nodeId, index, isInput } = nodeIdOrIdentifier
+ return `${nodeId}-${isInput ? 'in' : 'out'}-${index}`
+ }
+
+ if (index === undefined || isInput === undefined) {
+ throw new Error('Missing required parameters for slot key generation')
+ }
+
+ return `${nodeIdOrIdentifier}-${isInput ? 'in' : 'out'}-${index}`
+}
diff --git a/src/renderer/core/layout/slots/useDomSlotRegistration.ts b/src/renderer/core/layout/slots/useDomSlotRegistration.ts
new file mode 100644
index 000000000..94a1f09e5
--- /dev/null
+++ b/src/renderer/core/layout/slots/useDomSlotRegistration.ts
@@ -0,0 +1,229 @@
+/**
+ * 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