+```
+
+## Performance Considerations
+
+1. **CSS Transforms During Drag**: No layout recalculation, GPU accelerated
+2. **Batch Position Updates**: Layout tree updates trigger single LiteGraph sync
+3. **Reactive Efficiency**: Vue's computed properties cache results
+4. **Spatial Indexing**: QuadTree integration for fast hit testing
+
+## Future Migration Path
+
+Currently: Vue nodes use CSS transforms, commit to layout tree on drag end
+Future: Each renderer owns complete interaction handling and layout state
\ No newline at end of file
diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts
index d6304bdcca..9cd76c9d57 100644
--- a/src/composables/graph/useGraphNodeManager.ts
+++ b/src/composables/graph/useGraphNodeManager.ts
@@ -4,6 +4,7 @@
*/
import { nextTick, reactive, readonly } from 'vue'
+import { layoutMutations } from '@/services/layoutMutations'
import type { WidgetValue } from '@/types/simplifiedWidget'
import type { SpatialIndexDebugInfo } from '@/types/spatialIndex'
@@ -482,6 +483,11 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
currentPos.y !== node.pos[1]
) {
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
+
+ // Push position change to layout store
+ // Source is already set to 'canvas' in detectChangesInRAF
+ void layoutMutations.moveNode(id, { x: node.pos[0], y: node.pos[1] })
+
return true
}
return false
@@ -499,6 +505,14 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
currentSize.height !== node.size[1]
) {
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
+
+ // Push size change to layout store
+ // Source is already set to 'canvas' in detectChangesInRAF
+ void layoutMutations.resizeNode(id, {
+ width: node.size[0],
+ height: node.size[1]
+ })
+
return true
}
return false
@@ -549,6 +563,9 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
let positionUpdates = 0
let sizeUpdates = 0
+ // Set source for all canvas-driven updates
+ layoutMutations.setSource('canvas')
+
// Process each node for changes
for (const node of graph._nodes) {
const id = String(node.id)
@@ -606,6 +623,15 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
}
spatialIndex.insert(id, bounds, id)
+ // Add node to layout store
+ layoutMutations.setSource('canvas')
+ void layoutMutations.createNode(id, {
+ position: { x: node.pos[0], y: node.pos[1] },
+ size: { width: node.size[0], height: node.size[1] },
+ zIndex: node.order || 0,
+ visible: true
+ })
+
// Call original callback if provided
if (originalCallback) {
void originalCallback(node)
@@ -624,6 +650,10 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Remove from spatial index
spatialIndex.remove(id)
+ // Remove node from layout store
+ layoutMutations.setSource('canvas')
+ void layoutMutations.deleteNode(id)
+
// Clean up all tracking references
nodeRefs.delete(id)
vueNodeData.delete(id)
diff --git a/src/composables/graph/useLayout.ts b/src/composables/graph/useLayout.ts
new file mode 100644
index 0000000000..c01315157f
--- /dev/null
+++ b/src/composables/graph/useLayout.ts
@@ -0,0 +1,303 @@
+/**
+ * Composable for integrating Vue components with the Layout system
+ *
+ * Uses customRef for shared write access and provides clean mutation API.
+ * CRDT-ready with operation tracking.
+ */
+import log from 'loglevel'
+import { computed, inject, onUnmounted } from 'vue'
+
+import { layoutMutations } from '@/services/layoutMutations'
+import { layoutStore } from '@/stores/layoutStore'
+import type { Bounds, NodeId, Point } from '@/types/layoutTypes'
+
+// Create a logger for layout debugging
+const logger = log.getLogger('layout')
+// In dev mode, always show debug logs
+if (import.meta.env.DEV) {
+ logger.setLevel('debug')
+}
+
+/**
+ * Main composable for accessing the layout system
+ */
+export function useLayout() {
+ return {
+ // Store access
+ store: layoutStore,
+
+ // Mutation API
+ mutations: layoutMutations,
+
+ // Reactive accessors
+ getNodeLayoutRef: (nodeId: NodeId) => layoutStore.getNodeLayoutRef(nodeId),
+ getAllNodes: () => layoutStore.getAllNodes(),
+ getNodesInBounds: (bounds: Bounds) => layoutStore.getNodesInBounds(bounds),
+
+ // Non-reactive queries (for performance)
+ queryNodeAtPoint: (point: Point) => layoutStore.queryNodeAtPoint(point),
+ queryNodesInBounds: (bounds: Bounds) =>
+ layoutStore.queryNodesInBounds(bounds)
+ }
+}
+
+/**
+ * Composable for individual Vue node components
+ * Uses customRef for shared write access with Canvas renderer
+ */
+export function useNodeLayout(nodeId: string) {
+ const { store, mutations } = useLayout()
+
+ // Get transform utilities from TransformPane if available
+ const transformState = inject('transformState') as
+ | {
+ canvasToScreen: (point: Point) => Point
+ screenToCanvas: (point: Point) => Point
+ }
+ | undefined
+
+ // Get the customRef for this node (shared write access)
+ const layoutRef = store.getNodeLayoutRef(nodeId)
+
+ logger.debug(`useNodeLayout initialized for node ${nodeId}`, {
+ hasLayout: !!layoutRef.value,
+ initialPosition: layoutRef.value?.position
+ })
+
+ // Computed properties for easy access
+ const position = computed(() => {
+ const layout = layoutRef.value
+ const pos = layout?.position ?? { x: 0, y: 0 }
+ logger.debug(`Node ${nodeId} position computed:`, {
+ pos,
+ hasLayout: !!layout,
+ layoutRefValue: layout
+ })
+ return pos
+ })
+ const size = computed(
+ () => layoutRef.value?.size ?? { width: 200, height: 100 }
+ )
+ const bounds = computed(
+ () =>
+ layoutRef.value?.bounds ?? {
+ x: position.value.x,
+ y: position.value.y,
+ width: size.value.width,
+ height: size.value.height
+ }
+ )
+ const isVisible = computed(() => layoutRef.value?.visible ?? true)
+ const zIndex = computed(() => layoutRef.value?.zIndex ?? 0)
+
+ // Drag state
+ let isDragging = false
+ let dragStartPos: Point | null = null
+ let dragStartMouse: Point | null = null
+
+ /**
+ * Start dragging the node
+ */
+ function startDrag(event: PointerEvent) {
+ if (!layoutRef.value) return
+
+ isDragging = true
+ dragStartPos = { ...position.value }
+ dragStartMouse = { x: event.clientX, y: event.clientY }
+
+ // Set mutation source
+ mutations.setSource('vue')
+
+ // Capture pointer
+ const target = event.target as HTMLElement
+ target.setPointerCapture(event.pointerId)
+ }
+
+ /**
+ * Handle drag movement
+ */
+ const handleDrag = (event: PointerEvent) => {
+ if (!isDragging || !dragStartPos || !dragStartMouse || !transformState) {
+ logger.debug(`Drag skipped for node ${nodeId}:`, {
+ isDragging,
+ hasDragStartPos: !!dragStartPos,
+ hasDragStartMouse: !!dragStartMouse,
+ hasTransformState: !!transformState
+ })
+ return
+ }
+
+ // Calculate mouse delta in screen coordinates
+ const mouseDelta = {
+ x: event.clientX - dragStartMouse.x,
+ y: event.clientY - dragStartMouse.y
+ }
+
+ // Convert to canvas coordinates
+ const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
+ const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
+ const canvasDelta = {
+ x: canvasWithDelta.x - canvasOrigin.x,
+ y: canvasWithDelta.y - canvasOrigin.y
+ }
+
+ // Calculate new position
+ const newPosition = {
+ x: dragStartPos.x + canvasDelta.x,
+ y: dragStartPos.y + canvasDelta.y
+ }
+
+ logger.debug(`Dragging node ${nodeId}:`, {
+ mouseDelta,
+ canvasDelta,
+ newPosition,
+ currentLayoutPos: layoutRef.value?.position
+ })
+
+ // Apply mutation through the layout system
+ mutations.moveNode(nodeId, newPosition)
+ }
+
+ /**
+ * End dragging
+ */
+ function endDrag(event: PointerEvent) {
+ if (!isDragging) return
+
+ isDragging = false
+ dragStartPos = null
+ dragStartMouse = null
+
+ // Release pointer
+ const target = event.target as HTMLElement
+ target.releasePointerCapture(event.pointerId)
+ }
+
+ /**
+ * Update node position directly (without drag)
+ */
+ function moveTo(position: Point) {
+ mutations.setSource('vue')
+ mutations.moveNode(nodeId, position)
+ }
+
+ /**
+ * Update node size
+ */
+ function resize(newSize: { width: number; height: number }) {
+ mutations.setSource('vue')
+ mutations.resizeNode(nodeId, newSize)
+ }
+
+ return {
+ // Reactive state (via customRef)
+ layoutRef,
+ position,
+ size,
+ bounds,
+ isVisible,
+ zIndex,
+
+ // Mutations
+ moveTo,
+ resize,
+
+ // Drag handlers
+ startDrag,
+ handleDrag,
+ endDrag,
+
+ // Computed styles for Vue templates
+ nodeStyle: computed(() => ({
+ position: 'absolute' as const,
+ left: `${position.value.x}px`,
+ top: `${position.value.y}px`,
+ width: `${size.value.width}px`,
+ height: `${size.value.height}px`,
+ zIndex: zIndex.value,
+ cursor: isDragging ? 'grabbing' : 'grab'
+ }))
+ }
+}
+
+/**
+ * Composable for syncing LiteGraph with the Layout system
+ * This replaces the bidirectional sync with a one-way sync
+ */
+export function useLayoutSync() {
+ const { store } = useLayout()
+
+ let unsubscribe: (() => void) | null = null
+
+ /**
+ * Start syncing from Layout system to LiteGraph
+ * This is one-way: Layout → LiteGraph only
+ */
+ function startSync(canvas: any) {
+ if (!canvas?.graph) return
+
+ // Subscribe to layout changes
+ unsubscribe = store.onChange((change) => {
+ logger.debug('Layout sync received change:', {
+ source: change.source,
+ nodeIds: change.nodeIds,
+ type: change.type
+ })
+
+ // Apply changes to LiteGraph regardless of source
+ // The layout store is the single source of truth
+ for (const nodeId of change.nodeIds) {
+ const layout = store.getNodeLayoutRef(nodeId).value
+ if (!layout) continue
+
+ const liteNode = canvas.graph.getNodeById(parseInt(nodeId))
+ if (!liteNode) continue
+
+ // Update position if changed
+ if (
+ liteNode.pos[0] !== layout.position.x ||
+ liteNode.pos[1] !== layout.position.y
+ ) {
+ logger.debug(`Updating LiteGraph node ${nodeId} position:`, {
+ from: { x: liteNode.pos[0], y: liteNode.pos[1] },
+ to: layout.position
+ })
+ liteNode.pos[0] = layout.position.x
+ liteNode.pos[1] = layout.position.y
+ }
+
+ // Update size if changed
+ if (
+ liteNode.size[0] !== layout.size.width ||
+ liteNode.size[1] !== layout.size.height
+ ) {
+ liteNode.size[0] = layout.size.width
+ liteNode.size[1] = layout.size.height
+ }
+ }
+
+ // Trigger single redraw for all changes
+ canvas.setDirty(true, true)
+ })
+ }
+
+ /**
+ * Stop syncing
+ */
+ function stopSync() {
+ if (unsubscribe) {
+ unsubscribe()
+ unsubscribe = null
+ }
+ }
+
+ // Auto-cleanup on unmount
+ onUnmounted(() => {
+ stopSync()
+ })
+
+ return {
+ startSync,
+ stopSync
+ }
+}
diff --git a/src/lib/litegraph/src/LGraphNode.ts b/src/lib/litegraph/src/LGraphNode.ts
index eaebc3ff80..3cb0a7d641 100644
--- a/src/lib/litegraph/src/LGraphNode.ts
+++ b/src/lib/litegraph/src/LGraphNode.ts
@@ -1967,6 +1967,14 @@ export class LGraphNode
move(deltaX: number, deltaY: number): void {
if (this.pinned) return
+ // If Vue nodes mode is enabled, skip LiteGraph's direct position update
+ // The layout store will handle the movement and sync back to LiteGraph
+ if (LiteGraph.vueNodesMode) {
+ // Vue nodes handle their own dragging through the layout store
+ // This prevents the snap-back issue from conflicting position updates
+ return
+ }
+
this.pos[0] += deltaX
this.pos[1] += deltaY
}
diff --git a/src/services/README.md b/src/services/README.md
index afefba70df..00061b771e 100644
--- a/src/services/README.md
+++ b/src/services/README.md
@@ -1,277 +1,155 @@
-# Services
+# Reactive Layout Services
-This directory contains the service layer for the ComfyUI frontend application. Services encapsulate application logic and functionality into organized, reusable modules.
-
-## Table of Contents
-
-- [Overview](#overview)
-- [Service Architecture](#service-architecture)
-- [Core Services](#core-services)
-- [Service Development Guidelines](#service-development-guidelines)
-- [Common Design Patterns](#common-design-patterns)
-
-## Overview
-
-Services in ComfyUI provide organized modules that implement the application's functionality and logic. They handle operations such as API communication, workflow management, user settings, and other essential features.
-
-The term "business logic" in this context refers to the code that implements the core functionality and behavior of the application - the rules, processes, and operations that make ComfyUI work as expected, separate from the UI display code.
-
-Services help organize related functionality into cohesive units, making the codebase more maintainable and testable. By centralizing related operations in services, the application achieves better separation of concerns, with UI components focusing on presentation and services handling functional operations.
+This directory contains the core implementations of the reactive layout system that bridges Vue node interactions with LiteGraph.
## Service Architecture
-The service layer in ComfyUI follows these architectural principles:
+```mermaid
+graph LR
+ subgraph "Services"
+ RLT[ReactiveLayoutTree
- Position/Bounds State
- Selection State]
+ RHT[ReactiveHitTester
- Spatial Queries
- QuadTree Integration]
+ end
-1. **Domain-driven**: Each service focuses on a specific domain of the application
-2. **Stateless when possible**: Services generally avoid maintaining internal state
-3. **Reusable**: Services can be used across multiple components
-4. **Testable**: Services are designed for easy unit testing
-5. **Isolated**: Services have clear boundaries and dependencies
+ subgraph "Renderers"
+ Canvas[Canvas Renderer
(LiteGraph)]
+ Vue[Vue Renderer
(DOM Nodes)]
+ end
-While services can interact with both UI components and stores (centralized state), they primarily focus on implementing functionality rather than managing state. The following diagram illustrates how services fit into the application architecture:
+ subgraph "Spatial Index"
+ QT[QuadTree
Spatial Index]
+ end
-```
-┌─────────────────────────────────────────────────────────┐
-│ UI Components │
-└────────────────────────────┬────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────┐
-│ Composables │
-└────────────────────────────┬────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────┐
-│ Services │
-│ │
-│ (Application Functionality) │
-└────────────────────────────┬────────────────────────────┘
- │
- ┌───────────┴───────────┐
- ▼ ▼
-┌───────────────────────────┐ ┌─────────────────────────┐
-│ Stores │ │ External APIs │
-│ (Centralized State) │ │ │
-└───────────────────────────┘ └─────────────────────────┘
-```
-
-## Core Services
-
-The following table lists ALL services in the system as of 2025-01-30:
-
-### Main Services
-
-| Service | Description | Category |
-|---------|-------------|----------|
-| autoQueueService.ts | Manages automatic queue execution | Execution |
-| colorPaletteService.ts | Handles color palette management and customization | UI |
-| comfyManagerService.ts | Manages ComfyUI application packages and updates | Manager |
-| comfyRegistryService.ts | Handles registration and discovery of ComfyUI extensions | Registry |
-| dialogService.ts | Provides dialog and modal management | UI |
-| extensionService.ts | Manages extension registration and lifecycle | Extensions |
-| keybindingService.ts | Handles keyboard shortcuts and keybindings | Input |
-| litegraphService.ts | Provides utilities for working with the LiteGraph library | Graph |
-| load3dService.ts | Manages 3D model loading and visualization | 3D |
-| nodeHelpService.ts | Provides node documentation and help | Nodes |
-| nodeOrganizationService.ts | Handles node organization and categorization | Nodes |
-| nodeSearchService.ts | Implements node search functionality | Search |
-| releaseService.ts | Manages application release information and updates | System |
-| subgraphService.ts | Handles subgraph operations and navigation | Graph |
-| workflowService.ts | Handles workflow operations (save, load, execute) | Workflows |
-
-### Gateway Services
-Located in `services/gateway/`:
-
-| Service | Description |
-|---------|-------------|
-| registrySearchGateway.ts | Gateway for registry search operations |
-
-### Provider Services
-Located in `services/providers/`:
-
-| Service | Description |
-|---------|-------------|
-| algoliaSearchProvider.ts | Implements search functionality using Algolia |
-| registrySearchProvider.ts | Provides registry search capabilities |
-
-## Service Development Guidelines
-
-In ComfyUI, services can be implemented using two approaches:
-
-### 1. Class-based Services
-
-For complex services with state management and multiple methods, class-based services are used:
-
-```typescript
-export class NodeSearchService {
- // Service state
- private readonly nodeFuseSearch: FuseSearch
- private readonly filters: Record>
-
- constructor(data: ComfyNodeDefImpl[]) {
- // Initialize state
- this.nodeFuseSearch = new FuseSearch(data, { /* options */ })
+ Canvas -->|Write| RLT
+ Vue -->|Write| RLT
+ RLT -->|Reactive Updates| Canvas
+ RLT -->|Reactive Updates| Vue
- // Setup filters
- this.filters = {
- inputType: new FuseFilter(/* options */),
- category: new FuseFilter(/* options */)
+ RHT -->|Query| QT
+ RLT -->|Sync Bounds| RHT
+ RHT -->|Hit Testing| Vue
+
+
+## ReactiveLayoutTree Implementation
+
+```mermaid
+classDiagram
+ class ReactiveLayoutTree {
+ -_nodePositions: Ref~Map~
+ -_nodeBounds: Ref~Map~
+ -_selectedNodes: Ref~Set~
+ +nodePositions: ComputedRef~Map~
+ +nodeBounds: ComputedRef~Map~
+ +selectedNodes: Ref~Set~
+ +updateNodePosition(nodeId, position)
+ +updateNodeBounds(nodeId, bounds)
+ +selectNodes(nodeIds, addToSelection)
+ +clearSelection()
}
- }
- public searchNode(query: string, filters: FuseFilterWithValue[] = []): ComfyNodeDefImpl[] {
- // Implementation
- return results
- }
-}
-```
-
-### 2. Composable-style Services
-
-For simpler services or those that need to integrate with Vue's reactivity system, we prefer using composable-style services:
-
-```typescript
-export function useNodeSearchService(initialData: ComfyNodeDefImpl[]) {
- // State (reactive if needed)
- const data = ref(initialData)
-
- // Search functionality
- function searchNodes(query: string) {
- // Implementation
- return results
- }
-
- // Additional methods
- function refreshData(newData: ComfyNodeDefImpl[]) {
- data.value = newData
- }
-
- // Return public API
- return {
- searchNodes,
- refreshData
- }
-}
-```
-
-When deciding between these approaches, consider:
-
-1. **Stateful vs. Stateless**: For stateful services, classes often provide clearer encapsulation
-2. **Reactivity needs**: If the service needs to be reactive, composable-style services integrate better with Vue's reactivity system
-3. **Complexity**: For complex services with many methods and internal state, classes can provide better organization
-4. **Testing**: Both approaches can be tested effectively, but composables may be simpler to test with Vue Test Utils
-
-### Service Template
-
-Here's a template for creating a new composable-style service:
-
-```typescript
-/**
- * Service for managing [domain/functionality]
- */
-export function useExampleService() {
- // Private state/functionality
- const cache = new Map()
-
- /**
- * Description of what this method does
- * @param param1 Description of parameter
- * @returns Description of return value
- */
- async function performOperation(param1: string) {
- try {
- // Implementation
- return result
- } catch (error) {
- // Error handling
- console.error(`Operation failed: ${error.message}`)
- throw error
+ class customRef {
+ <>
+ +track()
+ +trigger()
}
- }
-
- // Return public API
- return {
- performOperation
- }
-}
+
+ ReactiveLayoutTree --> customRef : uses for shared write access
```
-## Common Design Patterns
+### Key Features
+- Uses Vue's `customRef` to allow both renderers to write
+- Provides reactive computed properties for automatic updates
+- Maintains immutable update pattern (creates new Maps on change)
+- Supports both single and bulk updates
-Services in ComfyUI frequently use the following design patterns:
+## ReactiveHitTester Implementation
-### Caching and Request Deduplication
+```mermaid
+flowchart TB
+ subgraph "Hit Testing Flow"
+ Query[Spatial Query]
+ QT[QuadTree Index]
+ Candidates[Candidate Nodes]
+ Precise[Precise Bounds Check]
+ Result[Hit Test Result]
+ end
+
+ Query -->|Viewport Bounds| QT
+ QT -->|Fast Filter| Candidates
+ Candidates -->|Intersection Test| Precise
+ Precise --> Result
+
+ subgraph "Reactive Queries"
+ RP[Reactive Point Query]
+ RB[Reactive Bounds Query]
+ Auto[Auto-update on Layout Change]
+ end
+
+ RP --> Query
+ RB --> Query
+ Auto -.->|Triggers| RP
+ Auto -.->|Triggers| RB
+```
+
+### Performance Optimizations
+- Integrates with existing QuadTree spatial indexing
+- Two-phase hit testing: spatial index filter + precise bounds check
+- Reactive queries use Vue's computed for efficient caching
+- Direct queries available for immediate results during interactions
+
+## Data Synchronization
+
+```mermaid
+sequenceDiagram
+ participant LG as LiteGraph
+ participant LT as LayoutTree
+ participant HT as HitTester
+ participant SI as Spatial Index
+ participant VN as Vue Node
+
+ Note over LG,VN: Initial Sync
+ LG->>LT: Bulk position update
+ LT->>HT: Bounds changed (reactive)
+ HT->>SI: Batch update spatial index
+
+ Note over LG,VN: Vue Node Drag
+ VN->>VN: CSS transform (visual)
+ VN->>LT: updateNodePosition (on drag end)
+ LT->>LG: Position changed (reactive watch)
+ LT->>HT: Bounds changed (reactive)
+ HT->>SI: Update node in index
+ LG->>LG: Redraw canvas
+
+ Note over LG,VN: Canvas Drag
+ LG->>LG: Update node.pos
+ LG->>LT: Sync position (RAF)
+ LT->>HT: Bounds changed (reactive)
+ HT->>SI: Update node in index
+ LT->>VN: Position changed (reactive)
+```
+
+## Usage Example
```typescript
-export function useCachedService() {
- const cache = new Map()
- const pendingRequests = new Map()
-
- async function fetchData(key: string) {
- // Check cache first
- if (cache.has(key)) return cache.get(key)
-
- // Check if request is already in progress
- if (pendingRequests.has(key)) {
- return pendingRequests.get(key)
- }
-
- // Perform new request
- const requestPromise = fetch(`/api/${key}`)
- .then(response => response.json())
- .then(data => {
- cache.set(key, data)
- pendingRequests.delete(key)
- return data
- })
-
- pendingRequests.set(key, requestPromise)
- return requestPromise
- }
-
- return { fetchData }
-}
-```
+// In Vue component
+const { layoutTree, hitTester } = useReactiveLayout()
-### Factory Pattern
+// Initialize layout tree sync
+const { initializeSync } = useLiteGraphSync()
+initializeSync()
-```typescript
-export function useNodeFactory() {
- function createNode(type: string, config: Record) {
- // Create node based on type and configuration
- switch (type) {
- case 'basic':
- return { /* basic node implementation */ }
- case 'complex':
- return { /* complex node implementation */ }
- default:
- throw new Error(`Unknown node type: ${type}`)
- }
- }
-
- return { createNode }
-}
-```
+// In Vue node component
+const {
+ isDragging,
+ startDrag,
+ handleDrag,
+ endDrag,
+ dragStyle
+} = useVueNodeInteraction(nodeId)
-### Facade Pattern
-
-```typescript
-export function useWorkflowService(
- apiService,
- graphService,
- storageService
-) {
- // Provides a simple interface to complex subsystems
- async function saveWorkflow(name: string) {
- const graphData = graphService.serializeGraph()
- const storagePath = await storageService.getPath(name)
- return apiService.saveData(storagePath, graphData)
- }
-
- return { saveWorkflow }
-}
-```
-
-For more detailed information about the service layer pattern and its applications, refer to:
-- [Service Layer Pattern](https://en.wikipedia.org/wiki/Service_layer_pattern)
-- [Service-Orientation](https://en.wikipedia.org/wiki/Service-orientation)
\ No newline at end of file
+// Reactive position tracking
+const nodePos = hitTester.getNodePosition(nodeId)
+watch(nodePos, (newPos) => {
+ console.log('Node moved to:', newPos)
+})
+```
\ No newline at end of file
diff --git a/src/services/layoutMutations.ts b/src/services/layoutMutations.ts
new file mode 100644
index 0000000000..c3730c6042
--- /dev/null
+++ b/src/services/layoutMutations.ts
@@ -0,0 +1,150 @@
+/**
+ * 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 { layoutStore } from '@/stores/layoutStore'
+import type {
+ LayoutMutations,
+ NodeId,
+ NodeLayout,
+ Point,
+ Size
+} from '@/types/layoutTypes'
+
+class LayoutMutationsImpl implements LayoutMutations {
+ /**
+ * Set the current mutation source
+ */
+ setSource(source: 'canvas' | 'vue' | 'external'): void {
+ layoutStore.setSource(source)
+ }
+
+ /**
+ * Set the current actor (for CRDT)
+ */
+ setActor(actor: string): void {
+ layoutStore.setActor(actor)
+ }
+
+ /**
+ * Move a node to a new position
+ */
+ moveNode(nodeId: NodeId, position: Point): void {
+ const existing = layoutStore.getNodeLayoutRef(nodeId).value
+ if (!existing) return
+
+ layoutStore.applyOperation({
+ type: 'moveNode',
+ nodeId,
+ position,
+ previousPosition: existing.position,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Resize a node
+ */
+ resizeNode(nodeId: NodeId, size: Size): void {
+ const existing = layoutStore.getNodeLayoutRef(nodeId).value
+ if (!existing) return
+
+ layoutStore.applyOperation({
+ type: 'resizeNode',
+ nodeId,
+ size,
+ previousSize: existing.size,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Set node z-index
+ */
+ setNodeZIndex(nodeId: NodeId, zIndex: number): void {
+ const existing = layoutStore.getNodeLayoutRef(nodeId).value
+ if (!existing) return
+
+ layoutStore.applyOperation({
+ type: 'setNodeZIndex',
+ nodeId,
+ zIndex,
+ previousZIndex: existing.zIndex,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Create a new node
+ */
+ createNode(nodeId: NodeId, layout: Partial): void {
+ const fullLayout: NodeLayout = {
+ id: nodeId,
+ 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',
+ nodeId,
+ layout: fullLayout,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Delete a node
+ */
+ deleteNode(nodeId: NodeId): void {
+ const existing = layoutStore.getNodeLayoutRef(nodeId).value
+ if (!existing) return
+
+ layoutStore.applyOperation({
+ type: 'deleteNode',
+ nodeId,
+ previousLayout: existing,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Bring a node to the front (highest z-index)
+ */
+ 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
+ this.setNodeZIndex(nodeId, maxZIndex + 1)
+ }
+}
+
+// Create singleton instance
+export const layoutMutations = new LayoutMutationsImpl()
diff --git a/src/stores/layoutStore.ts b/src/stores/layoutStore.ts
new file mode 100644
index 0000000000..1a188d1e86
--- /dev/null
+++ b/src/stores/layoutStore.ts
@@ -0,0 +1,647 @@
+/**
+ * Layout Store - Single Source of Truth
+ *
+ * Uses Yjs for efficient local state management and future collaboration.
+ * CRDT ensures conflict-free operations for both single and multi-user scenarios.
+ */
+import log from 'loglevel'
+import { type ComputedRef, type Ref, computed, customRef } from 'vue'
+import * as Y from 'yjs'
+
+import type {
+ AnyLayoutOperation,
+ Bounds,
+ LayoutChange,
+ LayoutStore,
+ NodeId,
+ NodeLayout,
+ Point
+} from '@/types/layoutTypes'
+
+// Create logger for layout store
+const logger = log.getLogger('layout-store')
+// In dev mode, always show debug logs
+if (import.meta.env.DEV) {
+ logger.setLevel('debug')
+}
+
+class LayoutStoreImpl implements LayoutStore {
+ // Yjs document and shared data structures
+ private ydoc = new Y.Doc()
+ private ynodes: Y.Map> // Maps nodeId -> Y.Map containing NodeLayout data
+ private yoperations: Y.Array // Operation log
+
+ // Vue reactivity layer
+ private version = 0
+ private currentSource: 'canvas' | 'vue' | 'external' = 'external'
+ private currentActor = `user-${Math.random().toString(36).substr(2, 9)}` // Random actor ID
+
+ // Change listeners
+ private changeListeners = new Set<(change: LayoutChange) => void>()
+
+ // CustomRef cache and trigger functions
+ private nodeRefs = new Map>()
+ private nodeTriggers = new Map void>()
+
+ // Spatial index cache
+ private spatialQueryCache = new Map()
+
+ constructor() {
+ // Initialize Yjs data structures
+ this.ynodes = this.ydoc.getMap('nodes')
+ this.yoperations = this.ydoc.getArray('operations')
+
+ // Listen for Yjs changes and trigger Vue reactivity
+ this.ynodes.observe((event) => {
+ this.version++
+ this.spatialQueryCache.clear()
+
+ // Trigger all affected node refs
+ event.changes.keys.forEach((_change, key) => {
+ const trigger = this.nodeTriggers.get(key)
+ if (trigger) {
+ logger.debug(`Yjs change detected for node ${key}, triggering ref`)
+ trigger()
+ }
+ })
+ })
+
+ // Debug: Log layout operations
+ if (localStorage.getItem('layout-debug') === 'true') {
+ this.yoperations.observe((event) => {
+ const operations: AnyLayoutOperation[] = []
+ event.changes.added.forEach((item) => {
+ const content = item.content.getContent()
+ if (Array.isArray(content) && content.length > 0) {
+ operations.push(content[0] as AnyLayoutOperation)
+ }
+ })
+ console.log('Layout Operation:', operations)
+ })
+ }
+ }
+
+ /**
+ * Get or create a customRef for a node layout
+ */
+ getNodeLayoutRef(nodeId: NodeId): Ref {
+ let nodeRef = this.nodeRefs.get(nodeId)
+
+ if (!nodeRef) {
+ logger.debug(`Creating new layout ref for node ${nodeId}`)
+
+ nodeRef = customRef((track, trigger) => {
+ // Store the trigger so we can call it when Yjs changes
+ this.nodeTriggers.set(nodeId, trigger)
+
+ return {
+ get: () => {
+ track()
+ const ynode = this.ynodes.get(nodeId)
+ const layout = ynode ? this.yNodeToLayout(ynode) : null
+ logger.debug(`Layout ref GET for node ${nodeId}:`, {
+ position: layout?.position,
+ hasYnode: !!ynode,
+ version: this.version
+ })
+ return layout
+ },
+ set: (newLayout: NodeLayout | null) => {
+ if (newLayout === null) {
+ // Delete operation
+ const existing = this.ynodes.get(nodeId)
+ if (existing) {
+ this.applyOperation({
+ type: 'deleteNode',
+ nodeId,
+ timestamp: Date.now(),
+ source: this.currentSource,
+ actor: this.currentActor,
+ previousLayout: this.yNodeToLayout(existing)
+ })
+ }
+ } else {
+ // Update operation - detect what changed
+ const existing = this.ynodes.get(nodeId)
+ if (!existing) {
+ // Create operation
+ this.applyOperation({
+ type: 'createNode',
+ nodeId,
+ layout: newLayout,
+ timestamp: Date.now(),
+ source: this.currentSource,
+ actor: this.currentActor
+ })
+ } else {
+ const existingLayout = this.yNodeToLayout(existing)
+
+ // Check what properties changed
+ if (
+ existingLayout.position.x !== newLayout.position.x ||
+ existingLayout.position.y !== newLayout.position.y
+ ) {
+ this.applyOperation({
+ type: 'moveNode',
+ nodeId,
+ position: newLayout.position,
+ previousPosition: existingLayout.position,
+ timestamp: Date.now(),
+ source: this.currentSource,
+ actor: this.currentActor
+ })
+ }
+ if (
+ existingLayout.size.width !== newLayout.size.width ||
+ existingLayout.size.height !== newLayout.size.height
+ ) {
+ this.applyOperation({
+ type: 'resizeNode',
+ nodeId,
+ size: newLayout.size,
+ previousSize: existingLayout.size,
+ timestamp: Date.now(),
+ source: this.currentSource,
+ actor: this.currentActor
+ })
+ }
+ if (existingLayout.zIndex !== newLayout.zIndex) {
+ this.applyOperation({
+ type: 'setNodeZIndex',
+ nodeId,
+ zIndex: newLayout.zIndex,
+ previousZIndex: existingLayout.zIndex,
+ timestamp: Date.now(),
+ source: this.currentSource,
+ actor: this.currentActor
+ })
+ }
+ }
+ }
+ logger.debug(`Layout ref SET triggering for node ${nodeId}`)
+ trigger()
+ }
+ }
+ })
+
+ this.nodeRefs.set(nodeId, nodeRef)
+ }
+
+ return nodeRef
+ }
+
+ /**
+ * Get nodes within bounds (reactive)
+ */
+ getNodesInBounds(bounds: Bounds): ComputedRef {
+ return computed(() => {
+ // Touch version for reactivity
+ void this.version
+
+ const result: NodeId[] = []
+ for (const [nodeId] of this.ynodes) {
+ const ynode = this.ynodes.get(nodeId)
+ if (ynode) {
+ const layout = this.yNodeToLayout(ynode)
+ if (layout && this.boundsIntersect(layout.bounds, bounds)) {
+ result.push(nodeId)
+ }
+ }
+ }
+ return result
+ })
+ }
+
+ /**
+ * Get all nodes as a reactive map
+ */
+ getAllNodes(): ComputedRef> {
+ return computed(() => {
+ // Touch version for reactivity
+ void this.version
+
+ const result = new Map()
+ for (const [nodeId] of this.ynodes) {
+ const ynode = this.ynodes.get(nodeId)
+ if (ynode) {
+ const layout = this.yNodeToLayout(ynode)
+ if (layout) {
+ result.set(nodeId, layout)
+ }
+ }
+ }
+ return result
+ })
+ }
+
+ /**
+ * Get current version for change detection
+ */
+ getVersion(): ComputedRef {
+ return computed(() => this.version)
+ }
+
+ /**
+ * Query node at point (non-reactive for performance)
+ */
+ queryNodeAtPoint(point: Point): NodeId | null {
+ const nodes: Array<[NodeId, NodeLayout]> = []
+
+ for (const [nodeId] of this.ynodes) {
+ const ynode = this.ynodes.get(nodeId)
+ if (ynode) {
+ const layout = this.yNodeToLayout(ynode)
+ if (layout) {
+ nodes.push([nodeId, layout])
+ }
+ }
+ }
+
+ // Sort by zIndex (top to bottom)
+ nodes.sort(([, a], [, b]) => b.zIndex - a.zIndex)
+
+ for (const [nodeId, layout] of nodes) {
+ if (this.pointInBounds(point, layout.bounds)) {
+ return nodeId
+ }
+ }
+
+ return null
+ }
+
+ /**
+ * Query nodes in bounds (non-reactive for performance)
+ */
+ queryNodesInBounds(bounds: Bounds): NodeId[] {
+ // Check cache first
+ const cacheKey = `${bounds.x},${bounds.y},${bounds.width},${bounds.height}`
+ const cached = this.spatialQueryCache.get(cacheKey)
+ if (cached) return cached
+
+ const result: NodeId[] = []
+ for (const [nodeId] of this.ynodes) {
+ const ynode = this.ynodes.get(nodeId)
+ if (ynode) {
+ const layout = this.yNodeToLayout(ynode)
+ if (layout && this.boundsIntersect(layout.bounds, bounds)) {
+ result.push(nodeId)
+ }
+ }
+ }
+
+ // Cache result
+ this.spatialQueryCache.set(cacheKey, result)
+ return result
+ }
+
+ /**
+ * Apply a layout operation using Yjs transactions
+ */
+ applyOperation(operation: AnyLayoutOperation): void {
+ logger.debug(`applyOperation called:`, {
+ type: operation.type,
+ nodeId: operation.nodeId,
+ operation
+ })
+
+ // Create change object outside transaction so we can use it after
+ const change: LayoutChange = {
+ type: 'update',
+ nodeIds: [],
+ timestamp: operation.timestamp,
+ source: operation.source,
+ operation
+ }
+
+ // Use Yjs transaction for atomic updates
+ this.ydoc.transact(() => {
+ // Add operation to log
+ this.yoperations.push([operation])
+
+ switch (operation.type) {
+ case 'moveNode':
+ this.handleMoveNode(operation, change)
+ break
+
+ case 'resizeNode':
+ this.handleResizeNode(operation, change)
+ break
+
+ case 'setNodeZIndex':
+ this.handleSetNodeZIndex(operation, change)
+ break
+
+ case 'createNode':
+ this.handleCreateNode(operation, change)
+ break
+
+ case 'deleteNode':
+ this.handleDeleteNode(operation, change)
+ break
+ }
+ }, this.currentActor) // Use actor as transaction origin
+
+ // Update version and clear cache
+ this.version++
+ this.spatialQueryCache.clear()
+
+ // Manually trigger affected node refs after transaction
+ // This is needed because Yjs observers don't fire for property changes
+ change.nodeIds.forEach((nodeId) => {
+ const trigger = this.nodeTriggers.get(nodeId)
+ if (trigger) {
+ logger.debug(
+ `Manually triggering ref for node ${nodeId} after operation`
+ )
+ trigger()
+ }
+ })
+
+ // Notify listeners (after transaction completes)
+ setTimeout(() => this.notifyChange(change), 0)
+ }
+
+ /**
+ * Subscribe to layout changes
+ */
+ onChange(callback: (change: LayoutChange) => void): () => void {
+ this.changeListeners.add(callback)
+ return () => this.changeListeners.delete(callback)
+ }
+
+ /**
+ * Set the current operation source
+ */
+ setSource(source: 'canvas' | 'vue' | 'external'): void {
+ this.currentSource = source
+ }
+
+ /**
+ * Set the current actor (for CRDT)
+ */
+ setActor(actor: string): void {
+ this.currentActor = actor
+ }
+
+ /**
+ * Get the current operation source
+ */
+ getCurrentSource(): 'canvas' | 'vue' | 'external' {
+ return this.currentSource
+ }
+
+ /**
+ * Get the current actor
+ */
+ getCurrentActor(): string {
+ return this.currentActor
+ }
+
+ /**
+ * Initialize store with existing nodes
+ */
+ initializeFromLiteGraph(
+ nodes: Array<{ id: string; pos: [number, number]; size: [number, number] }>
+ ): void {
+ logger.debug('Initializing layout store from LiteGraph', {
+ nodeCount: nodes.length,
+ nodes: nodes.map((n) => ({ id: n.id, pos: n.pos }))
+ })
+
+ this.ydoc.transact(() => {
+ this.ynodes.clear()
+ this.nodeRefs.clear()
+ this.nodeTriggers.clear()
+
+ nodes.forEach((node, index) => {
+ const layout: NodeLayout = {
+ id: node.id.toString(),
+ position: { x: node.pos[0], y: node.pos[1] },
+ size: { width: node.size[0], height: node.size[1] },
+ zIndex: index,
+ visible: true,
+ bounds: {
+ x: node.pos[0],
+ y: node.pos[1],
+ width: node.size[0],
+ height: node.size[1]
+ }
+ }
+
+ this.ynodes.set(layout.id, this.layoutToYNode(layout))
+ logger.debug(
+ `Initialized node ${layout.id} at position:`,
+ layout.position
+ )
+ })
+ }, 'initialization')
+
+ logger.debug('Layout store initialization complete', {
+ totalNodes: this.ynodes.size
+ })
+ }
+
+ // Operation handlers
+ private handleMoveNode(
+ operation: AnyLayoutOperation,
+ change: LayoutChange
+ ): void {
+ if (operation.type !== 'moveNode') return
+
+ logger.debug(`handleMoveNode called for ${operation.nodeId}`, {
+ newPosition: operation.position
+ })
+
+ const ynode = this.ynodes.get(operation.nodeId)
+ if (!ynode) {
+ logger.warn(`No ynode found for ${operation.nodeId}`)
+ return
+ }
+
+ // Update position in Yjs map
+ ynode.set('position', {
+ x: operation.position.x,
+ y: operation.position.y
+ })
+
+ // Update bounds
+ const size = ynode.get('size') as { width: number; height: number }
+ ynode.set('bounds', {
+ x: operation.position.x,
+ y: operation.position.y,
+ width: size.width,
+ height: size.height
+ })
+
+ change.nodeIds.push(operation.nodeId)
+ }
+
+ private handleResizeNode(
+ operation: AnyLayoutOperation,
+ change: LayoutChange
+ ): void {
+ if (operation.type !== 'resizeNode') return
+
+ const ynode = this.ynodes.get(operation.nodeId)
+ if (!ynode) return
+
+ // Update size in Yjs map
+ ynode.set('size', {
+ width: operation.size.width,
+ height: operation.size.height
+ })
+
+ // Update bounds
+ const position = ynode.get('position') as Point
+ ynode.set('bounds', {
+ x: position.x,
+ y: position.y,
+ width: operation.size.width,
+ height: operation.size.height
+ })
+
+ change.nodeIds.push(operation.nodeId)
+ }
+
+ private handleSetNodeZIndex(
+ operation: AnyLayoutOperation,
+ change: LayoutChange
+ ): void {
+ if (operation.type !== 'setNodeZIndex') return
+
+ const ynode = this.ynodes.get(operation.nodeId)
+ if (!ynode) return
+
+ ynode.set('zIndex', operation.zIndex)
+ change.nodeIds.push(operation.nodeId)
+ }
+
+ private handleCreateNode(
+ operation: AnyLayoutOperation,
+ change: LayoutChange
+ ): void {
+ if (operation.type !== 'createNode') return
+
+ const ynode = this.layoutToYNode(operation.layout)
+ this.ynodes.set(operation.nodeId, ynode)
+
+ change.type = 'create'
+ change.nodeIds.push(operation.nodeId)
+ }
+
+ private handleDeleteNode(
+ operation: AnyLayoutOperation,
+ change: LayoutChange
+ ): void {
+ if (operation.type !== 'deleteNode') return
+
+ const hadNode = this.ynodes.has(operation.nodeId)
+ this.ynodes.delete(operation.nodeId)
+
+ if (hadNode) {
+ this.nodeRefs.delete(operation.nodeId)
+ this.nodeTriggers.delete(operation.nodeId)
+ change.type = 'delete'
+ change.nodeIds.push(operation.nodeId)
+ }
+ }
+
+ // Helper methods
+ private layoutToYNode(layout: NodeLayout): Y.Map {
+ const ynode = new Y.Map()
+ ynode.set('id', layout.id)
+ ynode.set('position', layout.position)
+ ynode.set('size', layout.size)
+ ynode.set('zIndex', layout.zIndex)
+ ynode.set('visible', layout.visible)
+ ynode.set('bounds', layout.bounds)
+ return ynode
+ }
+
+ private yNodeToLayout(ynode: Y.Map): NodeLayout {
+ return {
+ id: ynode.get('id') as string,
+ position: ynode.get('position') as Point,
+ size: ynode.get('size') as { width: number; height: number },
+ zIndex: ynode.get('zIndex') as number,
+ visible: ynode.get('visible') as boolean,
+ bounds: ynode.get('bounds') as Bounds
+ }
+ }
+
+ private notifyChange(change: LayoutChange): void {
+ this.changeListeners.forEach((listener) => {
+ try {
+ listener(change)
+ } catch (error) {
+ console.error('Error in layout change listener:', error)
+ }
+ })
+ }
+
+ private pointInBounds(point: Point, bounds: Bounds): boolean {
+ return (
+ point.x >= bounds.x &&
+ point.x <= bounds.x + bounds.width &&
+ point.y >= bounds.y &&
+ point.y <= bounds.y + bounds.height
+ )
+ }
+
+ private boundsIntersect(a: Bounds, b: Bounds): boolean {
+ return !(
+ a.x + a.width < b.x ||
+ b.x + b.width < a.x ||
+ a.y + a.height < b.y ||
+ b.y + b.height < a.y
+ )
+ }
+
+ // CRDT-specific methods
+ getOperationsSince(timestamp: number): AnyLayoutOperation[] {
+ const operations: AnyLayoutOperation[] = []
+ this.yoperations.forEach((op) => {
+ if (op && (op as AnyLayoutOperation).timestamp > timestamp) {
+ operations.push(op as AnyLayoutOperation)
+ }
+ })
+ return operations
+ }
+
+ getOperationsByActor(actor: string): AnyLayoutOperation[] {
+ const operations: AnyLayoutOperation[] = []
+ this.yoperations.forEach((op) => {
+ if (op && (op as AnyLayoutOperation).actor === actor) {
+ operations.push(op as AnyLayoutOperation)
+ }
+ })
+ return operations
+ }
+
+ /**
+ * Get the Yjs document for network sync (future feature)
+ */
+ getYDoc(): Y.Doc {
+ return this.ydoc
+ }
+
+ /**
+ * Apply updates from remote peers (future feature)
+ */
+ applyUpdate(update: Uint8Array): void {
+ Y.applyUpdate(this.ydoc, update)
+ }
+
+ /**
+ * Get state as update for sending to peers (future feature)
+ */
+ getStateAsUpdate(): Uint8Array {
+ return Y.encodeStateAsUpdate(this.ydoc)
+ }
+}
+
+// Create singleton instance
+export const layoutStore = new LayoutStoreImpl()
+
+// Export types for convenience
+export type { LayoutStore } from '@/types/layoutTypes'
diff --git a/src/types/README.md b/src/types/README.md
new file mode 100644
index 0000000000..04bccb1273
--- /dev/null
+++ b/src/types/README.md
@@ -0,0 +1,81 @@
+# Reactive Layout Types
+
+This directory contains type definitions for the reactive layout system that enables Vue nodes to handle their own interactions while staying synchronized with LiteGraph.
+
+## Architecture Overview
+
+```mermaid
+graph TB
+ subgraph "Type Definitions"
+ Point[Point: x, y]
+ Size[Size: width, height]
+ Bounds[Bounds: x, y, width, height]
+ SlotRef[SlotRef: nodeId, slotIndex, isOutput]
+ end
+
+ subgraph "Core Interfaces"
+ LayoutTree[LayoutTree
- nodePositions
- nodeBounds
- selectedNodes]
+ HitTester[HitTester
- getNodeAt
- getNodesInBounds]
+ GraphMutationService[GraphMutationService
- moveNode
- selectNode
- connectNodes]
+ InteractionState[InteractionState
- dragState
- selectionState]
+ end
+
+ subgraph "Renderer Interface"
+ GraphRenderer[GraphRenderer
- setLayoutTree
- render
- mount/unmount]
+ end
+
+ Point --> Bounds
+ Size --> Bounds
+ Bounds --> LayoutTree
+ Bounds --> HitTester
+ Point --> GraphMutationService
+ SlotRef --> GraphMutationService
+ LayoutTree --> GraphRenderer
+ HitTester --> GraphRenderer
+
+
+## Data Flow During Interactions
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant VueNode
+ participant LayoutTree
+ participant LiteGraph
+
+ User->>VueNode: Drag Start
+ VueNode->>VueNode: Apply CSS Transform
+ Note over VueNode: Visual feedback only
+
+ User->>VueNode: Drag Move
+ VueNode->>VueNode: Update CSS Transform
+ Note over VueNode: Smooth dragging
+
+ User->>VueNode: Drag End
+ VueNode->>LayoutTree: updateNodePosition()
+ LayoutTree->>LiteGraph: Reactive sync
+ LiteGraph->>LiteGraph: Update canvas
+```
+
+## Key Interfaces
+
+### LayoutTree
+- Manages spatial/visual information reactively
+- Provides reactive getters for positions, bounds, and selection
+- Allows both Canvas and Vue renderers to update during transition
+
+### HitTester
+- Provides spatial queries (find nodes at point, in bounds)
+- Offers both reactive (auto-updating) and direct queries
+- Integrates with QuadTree spatial indexing for performance
+
+### GraphMutationService
+- Future API for all graph data changes
+- Separates data mutations from layout updates
+- Will be the single point of access for graph modifications
+
+### InteractionState
+- Tracks user interactions reactively
+- Manages drag and selection state
+- Provides actions for state transitions
+
\ No newline at end of file
diff --git a/src/types/layoutTypes.ts b/src/types/layoutTypes.ts
new file mode 100644
index 0000000000..689720dcac
--- /dev/null
+++ b/src/types/layoutTypes.ts
@@ -0,0 +1,234 @@
+/**
+ * Layout System - Type Definitions
+ *
+ * This file contains all type definitions for the layout system
+ * that manages node positions, bounds, and spatial data.
+ */
+import type { ComputedRef, Ref } from 'vue'
+
+// Basic geometric types
+export interface Point {
+ x: number
+ y: number
+}
+
+export interface Size {
+ width: number
+ height: number
+}
+
+export interface Bounds {
+ x: number
+ y: number
+ width: number
+ height: number
+}
+
+// ID types for type safety
+export type NodeId = string
+export type SlotId = string
+export type ConnectionId = string
+
+// Layout data structures
+export interface NodeLayout {
+ id: NodeId
+ position: Point
+ size: Size
+ zIndex: number
+ visible: boolean
+ // Computed bounds for hit testing
+ bounds: Bounds
+}
+
+export interface SlotLayout {
+ id: SlotId
+ nodeId: NodeId
+ position: Point // Relative to node
+ type: 'input' | 'output'
+ index: number
+}
+
+export interface ConnectionLayout {
+ id: ConnectionId
+ sourceSlot: SlotId
+ targetSlot: SlotId
+ // Control points for curved connections
+ controlPoints?: Point[]
+}
+
+// Mutation types
+export type LayoutMutationType =
+ | 'moveNode'
+ | 'resizeNode'
+ | 'setNodeZIndex'
+ | 'createNode'
+ | 'deleteNode'
+ | 'batch'
+
+export interface LayoutMutation {
+ type: LayoutMutationType
+ timestamp: number
+ source: 'canvas' | 'vue' | 'external'
+}
+
+export interface MoveNodeMutation extends LayoutMutation {
+ type: 'moveNode'
+ nodeId: NodeId
+ position: Point
+ previousPosition?: Point
+}
+
+export interface ResizeNodeMutation extends LayoutMutation {
+ type: 'resizeNode'
+ nodeId: NodeId
+ size: Size
+ previousSize?: Size
+}
+
+export interface SetNodeZIndexMutation extends LayoutMutation {
+ type: 'setNodeZIndex'
+ nodeId: NodeId
+ zIndex: number
+ previousZIndex?: number
+}
+
+export interface CreateNodeMutation extends LayoutMutation {
+ type: 'createNode'
+ nodeId: NodeId
+ layout: NodeLayout
+}
+
+export interface DeleteNodeMutation extends LayoutMutation {
+ type: 'deleteNode'
+ nodeId: NodeId
+ previousLayout?: NodeLayout
+}
+
+export interface BatchMutation extends LayoutMutation {
+ type: 'batch'
+ mutations: AnyLayoutMutation[]
+}
+
+// Union type for all mutations
+export type AnyLayoutMutation =
+ | MoveNodeMutation
+ | ResizeNodeMutation
+ | SetNodeZIndexMutation
+ | CreateNodeMutation
+ | DeleteNodeMutation
+ | BatchMutation
+
+// Change notification types
+export interface LayoutChange {
+ type: 'create' | 'update' | 'delete'
+ nodeIds: NodeId[]
+ timestamp: number
+ source: 'canvas' | 'vue' | 'external'
+ operation: AnyLayoutOperation
+}
+
+// Store interfaces
+export interface LayoutStore {
+ // CustomRef accessors for shared write access
+ getNodeLayoutRef(nodeId: NodeId): Ref
+ getNodesInBounds(bounds: Bounds): ComputedRef
+ getAllNodes(): ComputedRef>
+ getVersion(): ComputedRef
+
+ // Spatial queries (non-reactive)
+ queryNodeAtPoint(point: Point): NodeId | null
+ queryNodesInBounds(bounds: Bounds): NodeId[]
+
+ // Direct mutation API (CRDT-ready)
+ applyOperation(operation: AnyLayoutOperation): void
+
+ // Change subscription
+ onChange(callback: (change: LayoutChange) => void): () => void
+
+ // Initialization
+ initializeFromLiteGraph(
+ nodes: Array<{ id: string; pos: [number, number]; size: [number, number] }>
+ ): void
+
+ // Source and actor management
+ setSource(source: 'canvas' | 'vue' | 'external'): void
+ setActor(actor: string): void
+ getCurrentSource(): 'canvas' | 'vue' | 'external'
+ getCurrentActor(): string
+}
+
+// Operation tracking for CRDT compatibility
+export interface LayoutOperation {
+ type: LayoutMutationType
+ nodeId?: NodeId
+ timestamp: number
+ source: 'canvas' | 'vue' | 'external'
+ actor?: string // For CRDT - identifies who made the change
+}
+
+export interface MoveOperation extends LayoutOperation {
+ type: 'moveNode'
+ nodeId: NodeId
+ position: Point
+ previousPosition?: Point
+}
+
+export interface ResizeOperation extends LayoutOperation {
+ type: 'resizeNode'
+ nodeId: NodeId
+ size: Size
+ previousSize?: Size
+}
+
+export interface CreateOperation extends LayoutOperation {
+ type: 'createNode'
+ nodeId: NodeId
+ layout: NodeLayout
+}
+
+export interface DeleteOperation extends LayoutOperation {
+ type: 'deleteNode'
+ nodeId: NodeId
+ previousLayout?: NodeLayout
+}
+
+export interface ZIndexOperation extends LayoutOperation {
+ type: 'setNodeZIndex'
+ nodeId: NodeId
+ zIndex: number
+ previousZIndex?: number
+}
+
+export type AnyLayoutOperation =
+ | MoveOperation
+ | ResizeOperation
+ | CreateOperation
+ | DeleteOperation
+ | ZIndexOperation
+
+// Simplified mutation API
+export 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
+
+ // Lifecycle operations
+ createNode(nodeId: NodeId, layout: Partial): void
+ deleteNode(nodeId: NodeId): void
+
+ // Stacking operations
+ bringNodeToFront(nodeId: NodeId): void
+
+ // Source tracking
+ setSource(source: 'canvas' | 'vue' | 'external'): void
+ setActor(actor: string): void // For CRDT
+}
+
+// CRDT-ready operation log (for future CRDT integration)
+export interface OperationLog {
+ operations: AnyLayoutOperation[]
+ addOperation(operation: AnyLayoutOperation): void
+ getOperationsSince(timestamp: number): AnyLayoutOperation[]
+ getOperationsByActor(actor: string): AnyLayoutOperation[]
+}
diff --git a/tests-ui/tests/stores/layoutStore.test.ts b/tests-ui/tests/stores/layoutStore.test.ts
new file mode 100644
index 0000000000..adca96eaf5
--- /dev/null
+++ b/tests-ui/tests/stores/layoutStore.test.ts
@@ -0,0 +1,248 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import { layoutStore } from '@/stores/layoutStore'
+import type { NodeLayout } from '@/types/layoutTypes'
+
+describe('layoutStore CRDT operations', () => {
+ beforeEach(() => {
+ // Clear the store before each test
+ layoutStore.initializeFromLiteGraph([])
+ })
+ // Helper to create test node data
+ const createTestNode = (id: string): NodeLayout => ({
+ id,
+ position: { x: 100, y: 100 },
+ size: { width: 200, height: 100 },
+ zIndex: 0,
+ visible: true,
+ bounds: { x: 100, y: 100, width: 200, height: 100 }
+ })
+
+ it('should create and retrieve nodes', () => {
+ const nodeId = 'test-node-1'
+ const layout = createTestNode(nodeId)
+
+ // Create node
+ layoutStore.setSource('external')
+ layoutStore.applyOperation({
+ type: 'createNode',
+ nodeId,
+ layout,
+ timestamp: Date.now(),
+ source: 'external',
+ actor: 'test'
+ })
+
+ // Retrieve node
+ const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
+ expect(nodeRef.value).toEqual(layout)
+ })
+
+ it('should move nodes', () => {
+ const nodeId = 'test-node-2'
+ const layout = createTestNode(nodeId)
+
+ // Create node first
+ layoutStore.applyOperation({
+ type: 'createNode',
+ nodeId,
+ layout,
+ timestamp: Date.now(),
+ source: 'external',
+ actor: 'test'
+ })
+
+ // Move node
+ const newPosition = { x: 200, y: 300 }
+ layoutStore.applyOperation({
+ type: 'moveNode',
+ nodeId,
+ position: newPosition,
+ previousPosition: layout.position,
+ timestamp: Date.now(),
+ source: 'vue',
+ actor: 'test'
+ })
+
+ // Verify position updated
+ const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
+ expect(nodeRef.value?.position).toEqual(newPosition)
+ })
+
+ it('should resize nodes', () => {
+ const nodeId = 'test-node-3'
+ const layout = createTestNode(nodeId)
+
+ // Create node
+ layoutStore.applyOperation({
+ type: 'createNode',
+ nodeId,
+ layout,
+ timestamp: Date.now(),
+ source: 'external',
+ actor: 'test'
+ })
+
+ // Resize node
+ const newSize = { width: 300, height: 150 }
+ layoutStore.applyOperation({
+ type: 'resizeNode',
+ nodeId,
+ size: newSize,
+ previousSize: layout.size,
+ timestamp: Date.now(),
+ source: 'canvas',
+ actor: 'test'
+ })
+
+ // Verify size updated
+ const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
+ expect(nodeRef.value?.size).toEqual(newSize)
+ })
+
+ it('should delete nodes', () => {
+ const nodeId = 'test-node-4'
+ const layout = createTestNode(nodeId)
+
+ // Create node
+ layoutStore.applyOperation({
+ type: 'createNode',
+ nodeId,
+ layout,
+ timestamp: Date.now(),
+ source: 'external',
+ actor: 'test'
+ })
+
+ // Delete node
+ layoutStore.applyOperation({
+ type: 'deleteNode',
+ nodeId,
+ previousLayout: layout,
+ timestamp: Date.now(),
+ source: 'external',
+ actor: 'test'
+ })
+
+ // Verify node deleted
+ const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
+ expect(nodeRef.value).toBeNull()
+ })
+
+ it('should handle source and actor tracking', async () => {
+ const nodeId = 'test-node-5'
+ const layout = createTestNode(nodeId)
+
+ // Set source and actor
+ layoutStore.setSource('vue')
+ layoutStore.setActor('user-123')
+
+ // Track change notifications AFTER setting source/actor
+ const changes: any[] = []
+ const unsubscribe = layoutStore.onChange((change) => {
+ changes.push(change)
+ })
+
+ // Create node
+ layoutStore.applyOperation({
+ type: 'createNode',
+ nodeId,
+ layout,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+
+ // Wait for async notification
+ await new Promise(resolve => setTimeout(resolve, 50))
+
+ expect(changes.length).toBeGreaterThanOrEqual(1)
+ const lastChange = changes[changes.length - 1]
+ expect(lastChange.source).toBe('vue')
+ expect(lastChange.operation.actor).toBe('user-123')
+
+ unsubscribe()
+ })
+
+ it('should query nodes by spatial bounds', () => {
+ const nodes = [
+ { id: 'node-a', position: { x: 0, y: 0 } },
+ { id: 'node-b', position: { x: 100, y: 100 } },
+ { id: 'node-c', position: { x: 250, y: 250 } }
+ ]
+
+ // Create nodes with proper bounds
+ nodes.forEach(({ id, position }) => {
+ const layout: NodeLayout = {
+ ...createTestNode(id),
+ position,
+ bounds: {
+ x: position.x,
+ y: position.y,
+ width: 200,
+ height: 100
+ }
+ }
+ layoutStore.applyOperation({
+ type: 'createNode',
+ nodeId: id,
+ layout,
+ timestamp: Date.now(),
+ source: 'external',
+ actor: 'test'
+ })
+ })
+
+ // Query nodes in bounds
+ const nodesInBounds = layoutStore.queryNodesInBounds({
+ x: 50,
+ y: 50,
+ width: 200,
+ height: 200
+ })
+
+ // node-a: (0,0) to (200,100) - overlaps with query bounds (50,50) to (250,250)
+ // node-b: (100,100) to (300,200) - overlaps with query bounds
+ // node-c: (250,250) to (450,350) - touches corner of query bounds
+ expect(nodesInBounds).toContain('node-a')
+ expect(nodesInBounds).toContain('node-b')
+ expect(nodesInBounds).toContain('node-c')
+ })
+
+ it('should maintain operation history', () => {
+ const nodeId = 'test-node-history'
+ const layout = createTestNode(nodeId)
+ const startTime = Date.now()
+
+ // Create node
+ layoutStore.applyOperation({
+ type: 'createNode',
+ nodeId,
+ layout,
+ timestamp: startTime,
+ source: 'external',
+ actor: 'test-actor'
+ })
+
+ // Move node
+ layoutStore.applyOperation({
+ type: 'moveNode',
+ nodeId,
+ position: { x: 150, y: 150 },
+ timestamp: startTime + 100,
+ source: 'vue',
+ actor: 'test-actor'
+ })
+
+ // Get operations by actor
+ const operations = layoutStore.getOperationsByActor('test-actor')
+ expect(operations.length).toBeGreaterThanOrEqual(2)
+ expect(operations[0].type).toBe('createNode')
+ expect(operations[1].type).toBe('moveNode')
+
+ // Get operations since timestamp
+ const recentOps = layoutStore.getOperationsSince(startTime + 50)
+ expect(recentOps.length).toBeGreaterThanOrEqual(1)
+ expect(recentOps[0].type).toBe('moveNode')
+ })
+})
\ No newline at end of file