feat: Implement CRDT-based layout system for Vue nodes (#4959)

* feat: Implement CRDT-based layout system for Vue nodes

Major refactor to solve snap-back issues and create single source of truth for node positions:

- Add Yjs-based CRDT layout store for conflict-free position management
- Implement layout mutations service with clean API
- Create Vue composables for layout access and node dragging
- Add one-way sync from layout store to LiteGraph
- Disable LiteGraph dragging when Vue nodes mode is enabled
- Add z-index management with bring-to-front on node interaction
- Add comprehensive TypeScript types for layout system
- Include unit tests for layout store operations
- Update documentation to reflect CRDT architecture

This provides a solid foundation for both single-user performance and future real-time collaboration features.

Co-Authored-By: Claude <noreply@anthropic.com>

* style: Apply linter fixes to layout system

* fix: Remove unnecessary README files and revert services README

- Remove unnecessary types/README.md file
- Revert unrelated changes to services/README.md
- Keep only relevant documentation for the layout system implementation

These were issues identified during PR review that needed to be addressed.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Clean up layout store and implement proper CRDT operations

- Created dedicated layoutOperations.ts with production-grade CRDT interfaces
- Integrated existing QuadTree spatial index instead of simple cache
- Split composables into separate files (useLayout, useNodeLayout, useLayoutSync)
- Cleaned up operation handlers using specific types instead of Extract
- Added proper operation interfaces with type guards and extensibility
- Updated all type references to use new operation structure

The layout store now properly uses the existing QuadTree infrastructure for
efficient spatial queries and follows CRDT best practices with well-defined
operation interfaces.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Extract services and split composables for better organization

- Created SpatialIndexManager to handle QuadTree operations separately
- Added LayoutAdapter interface for CRDT abstraction (Yjs, mock implementations)
- Split GraphNodeManager into focused composables:
  - useNodeWidgets: Widget state and callback management
  - useNodeChangeDetection: RAF-based geometry change detection
  - useNodeState: Node visibility and reactive state management
- Extracted constants for magic numbers and configuration values
- Updated layout store to use SpatialIndexManager and constants

This improves code organization, testability, and makes it easier to swap
CRDT implementations or mock services for testing.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add node slots to layout tree

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* Remove slots from layoutTypes

* Totally not scuffed renderer and adapter

* Revert "Totally not scuffed renderer and adapter"

This reverts commit 2b9d83efb8.

* Revert "Remove slots from layoutTypes"

This reverts commit 18f78ff786.

* Reapply "Add node slots to layout tree"

This reverts commit 236fecb549.

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* docs: Replace architecture docs with comprehensive ADR

- Add ADR-0002 for CRDT-based layout system decision
- Follow established ADR template with persuasive reasoning
- Include performance benefits, collaboration readiness, and architectural advantages
- Update ADR index

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
This commit is contained in:
Christian Byrne
2025-08-16 20:49:17 -07:00
committed by GitHub
parent 88a5fb0a44
commit 4f337be837
25 changed files with 3551 additions and 15 deletions

View File

@@ -0,0 +1,82 @@
/**
* Layout Adapter Interface
*
* Abstracts the underlying CRDT implementation to allow for different
* backends (Yjs, Automerge, etc.) and easier testing.
*/
import type { LayoutOperation } from '@/types/layoutOperations'
import type { NodeId, NodeLayout } from '@/types/layoutTypes'
/**
* Change event emitted by the adapter
*/
export interface AdapterChange {
/** Type of change */
type: 'set' | 'delete' | 'clear'
/** Affected node IDs */
nodeIds: NodeId[]
/** Actor who made the change */
actor?: string
}
/**
* Layout adapter interface for CRDT abstraction
*/
export interface LayoutAdapter {
/**
* Set a node's layout data
*/
setNode(nodeId: NodeId, layout: NodeLayout): void
/**
* Get a node's layout data
*/
getNode(nodeId: NodeId): NodeLayout | null
/**
* Delete a node
*/
deleteNode(nodeId: NodeId): void
/**
* Get all nodes
*/
getAllNodes(): Map<NodeId, NodeLayout>
/**
* Clear all nodes
*/
clear(): void
/**
* Add an operation to the log
*/
addOperation(operation: LayoutOperation): void
/**
* Get operations since a timestamp
*/
getOperationsSince(timestamp: number): LayoutOperation[]
/**
* Get operations by a specific actor
*/
getOperationsByActor(actor: string): LayoutOperation[]
/**
* Subscribe to changes
*/
subscribe(callback: (change: AdapterChange) => void): () => void
/**
* Transaction support for atomic updates
*/
transaction(fn: () => void, actor?: string): void
/**
* Network sync methods (for future use)
*/
getStateVector(): Uint8Array
getStateAsUpdate(): Uint8Array
applyUpdate(update: Uint8Array): void
}

View File

@@ -0,0 +1,137 @@
/**
* Mock Layout Adapter
*
* Simple in-memory implementation for testing without CRDT overhead.
*/
import type { LayoutOperation } from '@/types/layoutOperations'
import type { NodeId, NodeLayout } from '@/types/layoutTypes'
import type { AdapterChange, LayoutAdapter } from './layoutAdapter'
/**
* Mock implementation for testing
*/
export class MockLayoutAdapter implements LayoutAdapter {
private nodes = new Map<NodeId, NodeLayout>()
private operations: LayoutOperation[] = []
private changeCallbacks = new Set<(change: AdapterChange) => void>()
private currentActor?: string
setNode(nodeId: NodeId, layout: NodeLayout): void {
this.nodes.set(nodeId, { ...layout })
this.notifyChange({
type: 'set',
nodeIds: [nodeId],
actor: this.currentActor
})
}
getNode(nodeId: NodeId): NodeLayout | null {
const layout = this.nodes.get(nodeId)
return layout ? { ...layout } : null
}
deleteNode(nodeId: NodeId): void {
const existed = this.nodes.delete(nodeId)
if (existed) {
this.notifyChange({
type: 'delete',
nodeIds: [nodeId],
actor: this.currentActor
})
}
}
getAllNodes(): Map<NodeId, NodeLayout> {
// Return a copy to prevent external mutations
const copy = new Map<NodeId, NodeLayout>()
for (const [id, layout] of this.nodes) {
copy.set(id, { ...layout })
}
return copy
}
clear(): void {
const nodeIds = Array.from(this.nodes.keys())
this.nodes.clear()
this.operations = []
if (nodeIds.length > 0) {
this.notifyChange({
type: 'clear',
nodeIds,
actor: this.currentActor
})
}
}
addOperation(operation: LayoutOperation): void {
this.operations.push({ ...operation })
}
getOperationsSince(timestamp: number): LayoutOperation[] {
return this.operations
.filter((op) => op.timestamp > timestamp)
.map((op) => ({ ...op }))
}
getOperationsByActor(actor: string): LayoutOperation[] {
return this.operations
.filter((op) => op.actor === actor)
.map((op) => ({ ...op }))
}
subscribe(callback: (change: AdapterChange) => void): () => void {
this.changeCallbacks.add(callback)
return () => this.changeCallbacks.delete(callback)
}
transaction(fn: () => void, actor?: string): void {
const previousActor = this.currentActor
this.currentActor = actor
try {
fn()
} finally {
this.currentActor = previousActor
}
}
// Mock network sync methods
getStateVector(): Uint8Array {
return new Uint8Array([1, 2, 3]) // Mock data
}
getStateAsUpdate(): Uint8Array {
// Simple serialization for testing
const json = JSON.stringify({
nodes: Array.from(this.nodes.entries()),
operations: this.operations
})
return new TextEncoder().encode(json)
}
applyUpdate(update: Uint8Array): void {
// Simple deserialization for testing
const json = new TextDecoder().decode(update)
const data = JSON.parse(json) as {
nodes: Array<[NodeId, NodeLayout]>
operations: LayoutOperation[]
}
this.nodes.clear()
for (const [id, layout] of data.nodes) {
this.nodes.set(id, layout)
}
this.operations = data.operations
}
private notifyChange(change: AdapterChange): void {
this.changeCallbacks.forEach((callback) => {
try {
callback(change)
} catch (error) {
console.error('Error in mock adapter change callback:', error)
}
})
}
}

View File

@@ -0,0 +1,202 @@
/**
* Yjs Layout Adapter
*
* Implements the LayoutAdapter interface using Yjs as the CRDT backend.
* Provides efficient local state management with future collaboration support.
*/
import * as Y from 'yjs'
import type { LayoutOperation } from '@/types/layoutOperations'
import type { Bounds, NodeId, NodeLayout, Point } from '@/types/layoutTypes'
import type { AdapterChange, LayoutAdapter } from './layoutAdapter'
/**
* Yjs implementation of the layout adapter
*/
export class YjsLayoutAdapter implements LayoutAdapter {
private ydoc: Y.Doc
private ynodes: Y.Map<Y.Map<unknown>>
private yoperations: Y.Array<LayoutOperation>
private changeCallbacks = new Set<(change: AdapterChange) => void>()
constructor() {
this.ydoc = new Y.Doc()
this.ynodes = this.ydoc.getMap('nodes')
this.yoperations = this.ydoc.getArray('operations')
// Set up change observation
this.ynodes.observe((event, transaction) => {
const change: AdapterChange = {
type: 'set', // Yjs doesn't distinguish set/delete in observe
nodeIds: [],
actor: transaction.origin as string | undefined
}
// Collect affected node IDs
event.changes.keys.forEach((changeType, key) => {
change.nodeIds.push(key)
if (changeType.action === 'delete') {
change.type = 'delete'
}
})
// Notify subscribers
this.notifyChange(change)
})
}
/**
* Set a node's layout data
*/
setNode(nodeId: NodeId, layout: NodeLayout): void {
const ynode = this.layoutToYNode(layout)
this.ynodes.set(nodeId, ynode)
}
/**
* Get a node's layout data
*/
getNode(nodeId: NodeId): NodeLayout | null {
const ynode = this.ynodes.get(nodeId)
return ynode ? this.yNodeToLayout(ynode) : null
}
/**
* Delete a node
*/
deleteNode(nodeId: NodeId): void {
this.ynodes.delete(nodeId)
}
/**
* Get all nodes
*/
getAllNodes(): Map<NodeId, NodeLayout> {
const result = new Map<NodeId, NodeLayout>()
for (const [nodeId] of this.ynodes) {
const ynode = this.ynodes.get(nodeId)
if (ynode) {
result.set(nodeId, this.yNodeToLayout(ynode))
}
}
return result
}
/**
* Clear all nodes
*/
clear(): void {
this.ynodes.clear()
}
/**
* Add an operation to the log
*/
addOperation(operation: LayoutOperation): void {
this.yoperations.push([operation])
}
/**
* Get operations since a timestamp
*/
getOperationsSince(timestamp: number): LayoutOperation[] {
const operations: LayoutOperation[] = []
this.yoperations.forEach((op) => {
if (op && op.timestamp > timestamp) {
operations.push(op)
}
})
return operations
}
/**
* Get operations by a specific actor
*/
getOperationsByActor(actor: string): LayoutOperation[] {
const operations: LayoutOperation[] = []
this.yoperations.forEach((op) => {
if (op && op.actor === actor) {
operations.push(op)
}
})
return operations
}
/**
* Subscribe to changes
*/
subscribe(callback: (change: AdapterChange) => void): () => void {
this.changeCallbacks.add(callback)
return () => this.changeCallbacks.delete(callback)
}
/**
* Transaction support for atomic updates
*/
transaction(fn: () => void, actor?: string): void {
this.ydoc.transact(fn, actor)
}
/**
* Get the current state vector for sync
*/
getStateVector(): Uint8Array {
return Y.encodeStateVector(this.ydoc)
}
/**
* Get state as update for sending to peers
*/
getStateAsUpdate(): Uint8Array {
return Y.encodeStateAsUpdate(this.ydoc)
}
/**
* Apply updates from remote peers
*/
applyUpdate(update: Uint8Array): void {
Y.applyUpdate(this.ydoc, update)
}
/**
* Convert layout to Yjs structure
*/
private layoutToYNode(layout: NodeLayout): Y.Map<unknown> {
const ynode = new Y.Map<unknown>()
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
}
/**
* Convert Yjs structure to layout
*/
private yNodeToLayout(ynode: Y.Map<unknown>): 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
}
}
/**
* Notify all change subscribers
*/
private notifyChange(change: AdapterChange): void {
this.changeCallbacks.forEach((callback) => {
try {
callback(change)
} catch (error) {
console.error('Error in adapter change callback:', error)
}
})
}
}

View File

@@ -133,6 +133,8 @@ import type {
NodeState,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { useLayout } from '@/composables/graph/useLayout'
import { useLayoutSync } from '@/composables/graph/useLayoutSync'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
@@ -157,6 +159,7 @@ import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore } from '@/stores/graphStore'
import { layoutStore } from '@/stores/layoutStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
@@ -174,6 +177,7 @@ const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const toastStore = useToastStore()
const { mutations: layoutMutations } = useLayout()
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
@@ -316,6 +320,19 @@ const initializeNodeManager = () => {
nodeSizes.value = nodeManager.nodeSizes
detectChangesInRAF = nodeManager.detectChangesInRAF
Object.assign(performanceMetrics, nodeManager.performanceMetrics)
// Initialize layout system with existing nodes
const nodes = comfyApp.graph._nodes.map((node: any) => ({
id: node.id.toString(),
pos: node.pos,
size: node.size
}))
layoutStore.initializeFromLiteGraph(nodes)
// Initialize layout sync (one-way: Layout Store → LiteGraph)
const { startSync } = useLayoutSync()
startSync(canvasStore.canvas)
// Force computed properties to re-evaluate
nodeDataTrigger.value++
}
@@ -493,6 +510,13 @@ const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => {
}
canvasStore.canvas.selectNode(node)
// Bring node to front when clicked (similar to LiteGraph behavior)
// Skip if node is pinned
if (!node.flags?.pinned) {
layoutMutations.setSource('vue')
layoutMutations.bringNodeToFront(nodeData.id)
}
node.selected = true
canvasStore.updateSelectedItems()

View File

@@ -5,6 +5,7 @@
</div>
<div
v-else
:data-node-id="nodeData.id"
:class="[
'lg-node absolute border-2 rounded-lg',
'contain-layout contain-style contain-paint',
@@ -13,15 +14,22 @@
nodeData.mode === 4 ? 'opacity-50' : '', // bypassed
error ? 'border-red-500 bg-red-50' : '',
isDragging ? 'will-change-transform' : '',
lodCssClass
lodCssClass,
'hover:border-green-500' // Debug: visual feedback on hover
]"
:style="[
{
transform: `translate(${layoutPosition.x ?? position?.x ?? 0}px, ${(layoutPosition.y ?? position?.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
width: size ? `${size.width}px` : '200px',
height: size ? `${size.height}px` : 'auto',
backgroundColor: '#353535',
pointerEvents: 'auto'
},
dragStyle
]"
:style="{
transform: `translate(${position?.x ?? 0}px, ${(position?.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
width: size ? `${size.width}px` : '200px',
height: size ? `${size.height}px` : 'auto',
backgroundColor: '#353535'
}"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
>
<!-- Header only updates on title/color changes -->
<NodeHeader
@@ -78,11 +86,13 @@
</template>
<script setup lang="ts">
import log from 'loglevel'
import { computed, onErrorCaptured, ref, toRef, watch } from 'vue'
// Import the VueNodeData type
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { LODLevel, useLOD } from '@/composables/graph/useLOD'
import { useNodeLayout } from '@/composables/graph/useNodeLayout'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { LiteGraph } from '../../../lib/litegraph/src/litegraph'
@@ -91,6 +101,13 @@ import NodeHeader from './NodeHeader.vue'
import NodeSlots from './NodeSlots.vue'
import NodeWidgets from './NodeWidgets.vue'
// Create logger for vue nodes
const logger = log.getLogger('vue-nodes')
// In dev mode, always show debug logs
if (import.meta.env.DEV) {
logger.setLevel('debug')
}
// Extended props for main node component
interface LGraphNodeProps {
nodeData: VueNodeData
@@ -141,8 +158,38 @@ onErrorCaptured((error) => {
return false // Prevent error propagation
})
// Track dragging state for will-change optimization
// Use layout system for node position and dragging
const {
position: layoutPosition,
startDrag,
handleDrag: handleLayoutDrag,
endDrag
} = useNodeLayout(props.nodeData.id)
// Debug layout position
watch(
layoutPosition,
(newPos, oldPos) => {
logger.debug(`Layout position changed for node ${props.nodeData.id}:`, {
newPos,
oldPos,
layoutPositionValue: layoutPosition.value
})
},
{ immediate: true, deep: true }
)
logger.debug(`LGraphNode mounted for ${props.nodeData.id}`, {
layoutPosition: layoutPosition.value,
propsPosition: props.position,
nodeDataId: props.nodeData.id
})
// Drag state for styling
const isDragging = ref(false)
const dragStyle = computed(() => ({
cursor: isDragging.value ? 'grabbing' : 'grab'
}))
// Track collapsed state
const isCollapsed = ref(props.nodeData.flags?.collapsed ?? false)
@@ -170,9 +217,28 @@ const handlePointerDown = (event: PointerEvent) => {
console.warn('LGraphNode: nodeData is null/undefined in handlePointerDown')
return
}
// Start drag using layout system
isDragging.value = true
startDrag(event)
// Emit node-click for selection handling in GraphCanvas
emit('node-click', event, props.nodeData)
}
const handlePointerMove = (event: PointerEvent) => {
if (isDragging.value) {
void handleLayoutDrag(event)
}
}
const handlePointerUp = (event: PointerEvent) => {
if (isDragging.value) {
isDragging.value = false
void endDrag(event)
}
}
const handleCollapse = () => {
isCollapsed.value = !isCollapsed.value
// Emit event so parent can sync with LiteGraph if needed
@@ -194,11 +260,4 @@ const handleSlotClick = (
const handleTitleUpdate = (newTitle: string) => {
emit('update:title', props.nodeData.id, newTitle)
}
// Expose methods for parent to control dragging state
defineExpose({
setDragging(dragging: boolean) {
isDragging.value = dragging
}
})
</script>

View File

@@ -0,0 +1,211 @@
# Graph Composables - Reactive Layout System
This directory contains composables for the reactive layout system, enabling Vue nodes to handle their own interactions while maintaining synchronization with LiteGraph.
## Composable Architecture
```mermaid
graph TB
subgraph "Composables"
URL[useReactiveLayout<br/>- Singleton Management<br/>- Service Access]
UVNI[useVueNodeInteraction<br/>- Node Dragging<br/>- CSS Transforms]
ULGS[useLiteGraphSync<br/>- Bidirectional Sync<br/>- Position Updates]
end
subgraph "Services"
LT[ReactiveLayoutTree]
HT[ReactiveHitTester]
end
subgraph "Components"
GC[GraphCanvas]
VN[Vue Nodes]
TP[TransformPane]
end
URL --> LT
URL --> HT
UVNI --> URL
ULGS --> URL
GC --> ULGS
VN --> UVNI
TP --> URL
</mermaid>
## Interaction Flow
```mermaid
sequenceDiagram
participant User
participant VueNode
participant UVNI as useVueNodeInteraction
participant LT as LayoutTree
participant LG as LiteGraph
User->>VueNode: pointerdown
VueNode->>UVNI: startDrag(event)
UVNI->>UVNI: Set drag state
UVNI->>UVNI: Capture pointer
User->>VueNode: pointermove
VueNode->>UVNI: handleDrag(event)
UVNI->>UVNI: Calculate delta
UVNI->>VueNode: Update CSS transform
Note over VueNode: Visual feedback only
User->>VueNode: pointerup
VueNode->>UVNI: endDrag(event)
UVNI->>LT: updateNodePosition(finalPos)
LT->>LG: Trigger reactive sync
LG->>LG: Update canvas
```
## useReactiveLayout
Singleton management for the reactive layout system.
```mermaid
classDiagram
class useReactiveLayout {
+layoutTree: ComputedRef~ReactiveLayoutTree~
+hitTester: ComputedRef~ReactiveHitTester~
+nodePositions: ComputedRef~Map~
+nodeBounds: ComputedRef~Map~
+selectedNodes: ComputedRef~Set~
-initialize(): void
}
class Singleton {
<<pattern>>
Shared across all components
}
useReactiveLayout --> Singleton : implements
```
## useVueNodeInteraction
Handles individual node interactions with CSS transforms.
```mermaid
flowchart LR
subgraph "Drag State"
DS[isDragging<br/>dragDelta<br/>dragStartPos]
end
subgraph "Event Handlers"
SD[startDrag]
HD[handleDrag]
ED[endDrag]
end
subgraph "Computed Styles"
NS[nodeStyle<br/>- position<br/>- dimensions<br/>- z-index]
DGS[dragStyle<br/>- transform<br/>- transition]
end
SD --> DS
HD --> DS
ED --> DS
DS --> NS
DS --> DGS
```
### Transform Calculation
```mermaid
graph TB
subgraph "Mouse Delta"
MD[event.clientX/Y - startMouse]
end
subgraph "Canvas Transform"
CT[screenToCanvas conversion]
end
subgraph "Drag Delta"
DD[Canvas-space delta]
end
subgraph "CSS Transform"
CSS[translate(deltaX, deltaY)]
end
MD --> CT
CT --> DD
DD --> CSS
```
## useLiteGraphSync
Bidirectional synchronization between LiteGraph and the reactive layout tree.
```mermaid
stateDiagram-v2
[*] --> Initialize
Initialize --> SyncFromLiteGraph
SyncFromLiteGraph --> WatchLayoutTree
state WatchLayoutTree {
[*] --> Listening
Listening --> PositionChanged: Layout tree update
PositionChanged --> UpdateLiteGraph
UpdateLiteGraph --> TriggerRedraw
TriggerRedraw --> Listening
}
state SyncFromLiteGraph {
[*] --> ReadNodes
ReadNodes --> UpdateLayoutTree
UpdateLayoutTree --> [*]
}
```
## Integration Example
```typescript
// In GraphCanvas.vue
const { initializeSync } = useLiteGraphSync()
onMounted(() => {
initializeSync() // Start bidirectional sync
})
// In LGraphNode.vue
const {
isDragging,
startDrag,
handleDrag,
endDrag,
dragStyle,
updatePosition
} = useVueNodeInteraction(props.nodeData.id)
// Template
<div
:style="[
{
transform: `translate(${position.x}px, ${position.y}px)`,
// ... other styles
},
dragStyle // Applied during drag
]"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
>
```
## 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

View File

@@ -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)

View File

@@ -0,0 +1,31 @@
/**
* Main composable for accessing the layout system
*
* Provides unified access to the layout store and mutation API.
*/
import { layoutMutations } from '@/services/layoutMutations'
import { layoutStore } from '@/stores/layoutStore'
import type { Bounds, NodeId, Point } from '@/types/layoutTypes'
/**
* 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)
}
}

View File

@@ -0,0 +1,97 @@
/**
* Composable for syncing LiteGraph with the Layout system
*
* Implements one-way sync from Layout Store to LiteGraph.
* The layout store is the single source of truth.
*/
import log from 'loglevel'
import { onUnmounted } from 'vue'
import { layoutStore } from '@/stores/layoutStore'
// 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')
}
/**
* Composable for syncing LiteGraph with the Layout system
* This replaces the bidirectional sync with a one-way sync
*/
export function useLayoutSync() {
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 = layoutStore.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 = layoutStore.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
}
}

View File

@@ -0,0 +1,180 @@
/**
* Node Change Detection
*
* RAF-based change detection for node positions and sizes.
* Syncs LiteGraph changes to the layout system.
*/
import { reactive } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { layoutMutations } from '@/services/layoutMutations'
export interface ChangeDetectionMetrics {
updateTime: number
positionUpdates: number
sizeUpdates: number
rafUpdateCount: number
}
/**
* Change detection for node geometry
*/
export function useNodeChangeDetection(graph: LGraph) {
const metrics = reactive<ChangeDetectionMetrics>({
updateTime: 0,
positionUpdates: 0,
sizeUpdates: 0,
rafUpdateCount: 0
})
// Track last known positions/sizes
const lastSnapshot = new Map<
string,
{ pos: [number, number]; size: [number, number] }
>()
/**
* Detects position changes for a single node
*/
const detectPositionChanges = (
node: LGraphNode,
nodePositions: Map<string, { x: number; y: number }>
): boolean => {
const id = String(node.id)
const currentPos = nodePositions.get(id)
if (
!currentPos ||
currentPos.x !== node.pos[0] ||
currentPos.y !== node.pos[1]
) {
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
// Push position change to layout store
void layoutMutations.moveNode(id, { x: node.pos[0], y: node.pos[1] })
return true
}
return false
}
/**
* Detects size changes for a single node
*/
const detectSizeChanges = (
node: LGraphNode,
nodeSizes: Map<string, { width: number; height: number }>
): boolean => {
const id = String(node.id)
const currentSize = nodeSizes.get(id)
if (
!currentSize ||
currentSize.width !== node.size[0] ||
currentSize.height !== node.size[1]
) {
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
// Push size change to layout store
void layoutMutations.resizeNode(id, {
width: node.size[0],
height: node.size[1]
})
return true
}
return false
}
/**
* Main RAF change detection function
*/
const detectChanges = (
nodePositions: Map<string, { x: number; y: number }>,
nodeSizes: Map<string, { width: number; height: number }>,
onSpatialChange?: (node: LGraphNode, id: string) => void
) => {
const startTime = performance.now()
if (!graph?._nodes) return
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)
const posChanged = detectPositionChanges(node, nodePositions)
const sizeChanged = detectSizeChanges(node, nodeSizes)
if (posChanged) positionUpdates++
if (sizeChanged) sizeUpdates++
// Notify spatial change if needed
if ((posChanged || sizeChanged) && onSpatialChange) {
onSpatialChange(node, id)
}
}
// Update metrics
const endTime = performance.now()
metrics.updateTime = endTime - startTime
metrics.positionUpdates = positionUpdates
metrics.sizeUpdates = sizeUpdates
if (positionUpdates > 0 || sizeUpdates > 0) {
metrics.rafUpdateCount++
}
}
/**
* Take a snapshot of current node positions/sizes
*/
const takeSnapshot = () => {
if (!graph?._nodes) return
lastSnapshot.clear()
for (const node of graph._nodes) {
lastSnapshot.set(String(node.id), {
pos: [node.pos[0], node.pos[1]],
size: [node.size[0], node.size[1]]
})
}
}
/**
* Check if any nodes have changed since last snapshot
*/
const hasChangedSinceSnapshot = (): boolean => {
if (!graph?._nodes) return false
for (const node of graph._nodes) {
const id = String(node.id)
const last = lastSnapshot.get(id)
if (!last) continue
if (
last.pos[0] !== node.pos[0] ||
last.pos[1] !== node.pos[1] ||
last.size[0] !== node.size[0] ||
last.size[1] !== node.size[1]
) {
return true
}
}
return false
}
return {
metrics,
detectChanges,
detectPositionChanges,
detectSizeChanges,
takeSnapshot,
hasChangedSinceSnapshot
}
}

View File

@@ -0,0 +1,199 @@
/**
* Composable for individual Vue node components
*
* Uses customRef for shared write access with Canvas renderer.
* Provides dragging functionality and reactive layout state.
*/
import log from 'loglevel'
import { computed, inject } from 'vue'
import { layoutMutations } from '@/services/layoutMutations'
import { layoutStore } from '@/stores/layoutStore'
import type { 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')
}
/**
* Composable for individual Vue node components
* Uses customRef for shared write access with Canvas renderer
*/
export function useNodeLayout(nodeId: string) {
const store = layoutStore
const mutations = layoutMutations
// 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'
}))
}
}

View File

@@ -0,0 +1,260 @@
/**
* Node State Management
*
* Manages node visibility, dirty state, and other UI state.
* Provides reactive state for Vue components.
*/
import { nextTick, reactive, readonly } from 'vue'
import { PERFORMANCE_CONFIG } from '@/constants/layout'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SafeWidgetData, VueNodeData, WidgetValue } from './useNodeWidgets'
export interface NodeState {
visible: boolean
dirty: boolean
lastUpdate: number
culled: boolean
}
export interface NodeMetadata {
lastRenderTime: number
cachedBounds: DOMRect | null
lodLevel: 'high' | 'medium' | 'low'
}
/**
* Extract safe Vue data from LiteGraph node
*/
export function extractVueNodeData(
node: LGraphNode,
widgets?: SafeWidgetData[]
): VueNodeData {
return {
id: String(node.id),
title: node.title || 'Untitled',
type: node.type || 'Unknown',
mode: node.mode || 0,
selected: node.selected || false,
executing: false, // Will be updated separately based on execution state
widgets,
inputs: node.inputs ? [...node.inputs] : undefined,
outputs: node.outputs ? [...node.outputs] : undefined,
flags: node.flags ? { ...node.flags } : undefined
}
}
/**
* Node state management composable
*/
export function useNodeState() {
// Reactive state maps
const vueNodeData = reactive(new Map<string, VueNodeData>())
const nodeState = reactive(new Map<string, NodeState>())
const nodePositions = reactive(new Map<string, { x: number; y: number }>())
const nodeSizes = reactive(
new Map<string, { width: number; height: number }>()
)
// Non-reactive node references
const nodeRefs = new Map<string, LGraphNode>()
// WeakMap for heavy metadata that auto-GCs
const nodeMetadata = new WeakMap<LGraphNode, NodeMetadata>()
// Update batching
const pendingUpdates = new Set<string>()
const criticalUpdates = new Set<string>()
const lowPriorityUpdates = new Set<string>()
let updateScheduled = false
let batchTimeoutId: number | null = null
/**
* Attach metadata to a node
*/
const attachMetadata = (node: LGraphNode) => {
nodeMetadata.set(node, {
lastRenderTime: performance.now(),
cachedBounds: null,
lodLevel: 'high'
})
}
/**
* Get access to original LiteGraph node
*/
const getNode = (id: string): LGraphNode | undefined => {
return nodeRefs.get(id)
}
/**
* Schedule an update for a node
*/
const scheduleUpdate = (
nodeId?: string,
priority: 'critical' | 'normal' | 'low' = 'normal'
) => {
if (nodeId) {
const state = nodeState.get(nodeId)
if (state) state.dirty = true
// Priority queuing
if (priority === 'critical') {
criticalUpdates.add(nodeId)
flush() // Immediate flush for critical updates
return
} else if (priority === 'low') {
lowPriorityUpdates.add(nodeId)
} else {
pendingUpdates.add(nodeId)
}
}
if (!updateScheduled) {
updateScheduled = true
// Adaptive batching strategy
if (pendingUpdates.size > 10) {
// Many updates - batch in nextTick
void nextTick(() => flush())
} else {
// Few updates - small delay for more batching
batchTimeoutId = window.setTimeout(
() => flush(),
PERFORMANCE_CONFIG.BATCH_UPDATE_DELAY
)
}
}
}
/**
* Flush all pending updates
*/
const flush = () => {
if (batchTimeoutId !== null) {
clearTimeout(batchTimeoutId)
batchTimeoutId = null
}
// Clear all pending updates
criticalUpdates.clear()
pendingUpdates.clear()
lowPriorityUpdates.clear()
updateScheduled = false
// Trigger any additional update logic here
}
/**
* Initialize node state
*/
const initializeNode = (node: LGraphNode, vueData: VueNodeData): void => {
const id = String(node.id)
// Store references
nodeRefs.set(id, node)
vueNodeData.set(id, vueData)
// Initialize state
nodeState.set(id, {
visible: true,
dirty: false,
lastUpdate: performance.now(),
culled: false
})
// Initialize position and size
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
// Attach metadata
attachMetadata(node)
}
/**
* Clean up node state
*/
const cleanupNode = (nodeId: string): void => {
nodeRefs.delete(nodeId)
vueNodeData.delete(nodeId)
nodeState.delete(nodeId)
nodePositions.delete(nodeId)
nodeSizes.delete(nodeId)
}
/**
* Update node property
*/
const updateNodeProperty = (
nodeId: string,
property: string,
value: unknown
): void => {
const currentData = vueNodeData.get(nodeId)
if (!currentData) return
if (property === 'title') {
vueNodeData.set(nodeId, {
...currentData,
title: String(value)
})
} else if (property === 'flags.collapsed') {
vueNodeData.set(nodeId, {
...currentData,
flags: {
...currentData.flags,
collapsed: Boolean(value)
}
})
}
}
/**
* Update widget state
*/
const updateWidgetState = (
nodeId: string,
widgetName: string,
value: unknown
): void => {
const currentData = vueNodeData.get(nodeId)
if (!currentData?.widgets) return
const updatedWidgets = currentData.widgets.map((w) =>
w.name === widgetName ? { ...w, value: value as WidgetValue } : w
)
vueNodeData.set(nodeId, {
...currentData,
widgets: updatedWidgets
})
}
return {
// State maps (read-only)
vueNodeData: readonly(vueNodeData) as ReadonlyMap<string, VueNodeData>,
nodeState: readonly(nodeState) as ReadonlyMap<string, NodeState>,
nodePositions: readonly(nodePositions) as ReadonlyMap<
string,
{ x: number; y: number }
>,
nodeSizes: readonly(nodeSizes) as ReadonlyMap<
string,
{ width: number; height: number }
>,
// Methods
getNode,
attachMetadata,
scheduleUpdate,
flush,
initializeNode,
cleanupNode,
updateNodeProperty,
updateWidgetState,
// Mutable access for internal use
_mutableNodePositions: nodePositions,
_mutableNodeSizes: nodeSizes
}
}

View File

@@ -0,0 +1,182 @@
/**
* Node Widget Management
*
* Handles widget state synchronization between LiteGraph and Vue.
* Provides wrapped callbacks to maintain consistency.
*/
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { WidgetValue } from '@/types/simplifiedWidget'
export type { WidgetValue }
export interface SafeWidgetData {
name: string
type: string
value: WidgetValue
options?: Record<string, unknown>
callback?: ((value: unknown) => void) | undefined
}
export interface VueNodeData {
id: string
title: string
type: string
mode: number
selected: boolean
executing: boolean
widgets?: SafeWidgetData[]
inputs?: unknown[]
outputs?: unknown[]
flags?: {
collapsed?: boolean
}
}
/**
* Validates that a value is a valid WidgetValue type
*/
export function validateWidgetValue(value: unknown): WidgetValue {
if (value === null || value === undefined || value === void 0) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') {
// Check if it's a File array
if (Array.isArray(value) && value.every((item) => item instanceof File)) {
return value as File[]
}
// Otherwise it's a generic object
return value as object
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
/**
* Extract safe widget data from LiteGraph widgets
*/
export function extractWidgetData(
widgets?: any[]
): SafeWidgetData[] | undefined {
if (!widgets) return undefined
return widgets.map((widget) => {
try {
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
return {
name: widget.name,
type: widget.type,
value: validateWidgetValue(value),
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined,
options: undefined,
callback: undefined
}
}
})
}
/**
* Widget callback management for LiteGraph/Vue sync
*/
export function useNodeWidgets() {
/**
* Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
*/
const createWrappedCallback = (
widget: { value?: unknown; name: string },
originalCallback: ((value: unknown) => void) | undefined,
nodeId: string,
onUpdate: (nodeId: string, widgetName: string, value: unknown) => void
) => {
let updateInProgress = false
return (value: unknown) => {
if (updateInProgress) return
updateInProgress = true
try {
// Validate that the value is of an acceptable type
if (
value !== null &&
value !== undefined &&
typeof value !== 'string' &&
typeof value !== 'number' &&
typeof value !== 'boolean' &&
typeof value !== 'object'
) {
console.warn(`Invalid widget value type: ${typeof value}`)
updateInProgress = false
return
}
// Always update widget.value to ensure sync
widget.value = value
// Call the original callback if it exists
if (originalCallback) {
originalCallback.call(widget, value)
}
// Update Vue state to maintain synchronization
onUpdate(nodeId, widget.name, value)
} finally {
updateInProgress = false
}
}
}
/**
* Sets up widget callbacks for a node
*/
const setupNodeWidgetCallbacks = (
node: LGraphNode,
onUpdate: (nodeId: string, widgetName: string, value: unknown) => void
) => {
if (!node.widgets) return
const nodeId = String(node.id)
node.widgets.forEach((widget) => {
const originalCallback = widget.callback
widget.callback = createWrappedCallback(
widget,
originalCallback,
nodeId,
onUpdate
)
})
}
return {
validateWidgetValue,
extractWidgetData,
createWrappedCallback,
setupNodeWidgetCallbacks
}
}

73
src/constants/layout.ts Normal file
View File

@@ -0,0 +1,73 @@
/**
* Layout System Constants
*
* Centralized configuration values for the layout system.
* These values control spatial indexing, performance, and behavior.
*/
/**
* 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
/**
* Default values for node layout
*/
export const NODE_DEFAULTS = {
/** Default node size when not specified */
SIZE: { width: 200, height: 100 },
/** Default z-index for new nodes */
Z_INDEX: 0,
/** Default visibility state */
VISIBLE: true
} as const
/**
* Debug and development settings
*/
export const DEBUG_CONFIG = {
/** LocalStorage key for enabling layout debug mode */
LAYOUT_DEBUG_KEY: 'layout-debug',
/** Logger name for layout system */
LOGGER_NAME: 'layout',
/** Logger name for layout store */
STORE_LOGGER_NAME: 'layout-store'
} 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: 'external' as const
} as const

View File

@@ -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
}

View File

@@ -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<NodeLayout>): 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()

View File

@@ -0,0 +1,166 @@
/**
* Spatial Index Manager
*
* Manages spatial indexing for efficient node queries based on bounds.
* Uses QuadTree for fast spatial lookups with caching for performance.
*/
import { PERFORMANCE_CONFIG, QUADTREE_CONFIG } from '@/constants/layout'
import type { Bounds, NodeId } from '@/types/layoutTypes'
import { QuadTree } from '@/utils/spatial/QuadTree'
/**
* Cache entry for spatial queries
*/
interface CacheEntry {
result: NodeId[]
timestamp: number
}
/**
* Spatial index manager using QuadTree
*/
export class SpatialIndexManager {
private quadTree: QuadTree<NodeId>
private queryCache: Map<string, CacheEntry>
private cacheSize = 0
constructor(bounds?: Bounds) {
this.quadTree = new QuadTree<NodeId>(
bounds ?? QUADTREE_CONFIG.DEFAULT_BOUNDS,
{
maxDepth: QUADTREE_CONFIG.MAX_DEPTH,
maxItemsPerNode: QUADTREE_CONFIG.MAX_ITEMS_PER_NODE
}
)
this.queryCache = new Map()
}
/**
* Insert a node into the spatial index
*/
insert(nodeId: NodeId, bounds: Bounds): void {
this.quadTree.insert(nodeId, bounds, nodeId)
this.invalidateCache()
}
/**
* Update a node's bounds in the spatial index
*/
update(nodeId: NodeId, bounds: Bounds): void {
this.quadTree.update(nodeId, bounds)
this.invalidateCache()
}
/**
* Remove a node from the spatial index
*/
remove(nodeId: NodeId): void {
this.quadTree.remove(nodeId)
this.invalidateCache()
}
/**
* Query nodes within the given bounds
*/
query(bounds: Bounds): NodeId[] {
const cacheKey = this.getCacheKey(bounds)
const cached = this.queryCache.get(cacheKey)
// Check cache validity
if (cached) {
const age = Date.now() - cached.timestamp
if (age < PERFORMANCE_CONFIG.SPATIAL_CACHE_TTL) {
return cached.result
}
// Remove stale entry
this.queryCache.delete(cacheKey)
this.cacheSize--
}
// Perform query
const result = this.quadTree.query(bounds)
// Cache result
this.addToCache(cacheKey, result)
return result
}
/**
* Clear all nodes from the spatial index
*/
clear(): void {
this.quadTree.clear()
this.invalidateCache()
}
/**
* Get the current size of the index
*/
get size(): number {
return this.quadTree.size
}
/**
* Get debug information about the spatial index
*/
getDebugInfo() {
return {
quadTreeInfo: this.quadTree.getDebugInfo(),
cacheSize: this.cacheSize,
cacheEntries: this.queryCache.size
}
}
/**
* Generate cache key for bounds
*/
private getCacheKey(bounds: Bounds): string {
return `${bounds.x},${bounds.y},${bounds.width},${bounds.height}`
}
/**
* Add result to cache with LRU eviction
*/
private addToCache(key: string, result: NodeId[]): void {
// Evict oldest entries if cache is full
if (this.cacheSize >= PERFORMANCE_CONFIG.SPATIAL_CACHE_MAX_SIZE) {
const oldestKey = this.findOldestCacheEntry()
if (oldestKey) {
this.queryCache.delete(oldestKey)
this.cacheSize--
}
}
this.queryCache.set(key, {
result,
timestamp: Date.now()
})
this.cacheSize++
}
/**
* Find oldest cache entry for LRU eviction
*/
private findOldestCacheEntry(): string | null {
let oldestKey: string | null = null
let oldestTime = Infinity
for (const [key, entry] of this.queryCache) {
if (entry.timestamp < oldestTime) {
oldestTime = entry.timestamp
oldestKey = key
}
}
return oldestKey
}
/**
* Invalidate all cached queries
*/
private invalidateCache(): void {
this.queryCache.clear()
this.cacheSize = 0
}
}

665
src/stores/layoutStore.ts Normal file
View File

@@ -0,0 +1,665 @@
/**
* 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 { ACTOR_CONFIG, DEBUG_CONFIG } from '@/constants/layout'
import { SpatialIndexManager } from '@/services/spatialIndexManager'
import type {
CreateNodeOperation,
DeleteNodeOperation,
LayoutOperation,
MoveNodeOperation,
ResizeNodeOperation,
SetNodeZIndexOperation
} from '@/types/layoutOperations'
import type {
Bounds,
LayoutChange,
LayoutStore,
NodeId,
NodeLayout,
Point
} from '@/types/layoutTypes'
// Create logger for layout store
const logger = log.getLogger(DEBUG_CONFIG.STORE_LOGGER_NAME)
// 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<Y.Map<unknown>> // Maps nodeId -> Y.Map containing NodeLayout data
private yoperations: Y.Array<LayoutOperation> // Operation log
// Vue reactivity layer
private version = 0
private currentSource: 'canvas' | 'vue' | 'external' =
ACTOR_CONFIG.DEFAULT_SOURCE
private currentActor = `${ACTOR_CONFIG.USER_PREFIX}${Math.random()
.toString(36)
.substr(2, ACTOR_CONFIG.ID_LENGTH)}`
// Change listeners
private changeListeners = new Set<(change: LayoutChange) => void>()
// CustomRef cache and trigger functions
private nodeRefs = new Map<NodeId, Ref<NodeLayout | null>>()
private nodeTriggers = new Map<NodeId, () => void>()
// Spatial index manager
private spatialIndex: SpatialIndexManager
constructor() {
// Initialize Yjs data structures
this.ynodes = this.ydoc.getMap('nodes')
this.yoperations = this.ydoc.getArray('operations')
// Initialize spatial index manager
this.spatialIndex = new SpatialIndexManager()
// Listen for Yjs changes and trigger Vue reactivity
this.ynodes.observe((event) => {
this.version++
// 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(DEBUG_CONFIG.LAYOUT_DEBUG_KEY) === 'true') {
this.yoperations.observe((event) => {
const operations: LayoutOperation[] = []
event.changes.added.forEach((item) => {
const content = item.content.getContent()
if (Array.isArray(content) && content.length > 0) {
operations.push(content[0] as LayoutOperation)
}
})
console.log('Layout Operation:', operations)
})
}
}
/**
* Get or create a customRef for a node layout
*/
getNodeLayoutRef(nodeId: NodeId): Ref<NodeLayout | null> {
let nodeRef = this.nodeRefs.get(nodeId)
if (!nodeRef) {
logger.debug(`Creating new layout ref for node ${nodeId}`)
nodeRef = customRef<NodeLayout | null>((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<NodeId[]> {
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<ReadonlyMap<NodeId, NodeLayout>> {
return computed(() => {
// Touch version for reactivity
void this.version
const result = new Map<NodeId, NodeLayout>()
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<number> {
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[] {
return this.spatialIndex.query(bounds)
}
/**
* Apply a layout operation using Yjs transactions
*/
applyOperation(operation: LayoutOperation): 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])
// Apply the operation
this.applyOperationInTransaction(operation, change)
}, this.currentActor)
// Post-transaction updates
this.finalizeOperation(change)
}
/**
* Apply operation within a transaction
*/
private applyOperationInTransaction(
operation: LayoutOperation,
change: LayoutChange
): void {
switch (operation.type) {
case 'moveNode':
this.handleMoveNode(operation as MoveNodeOperation, change)
break
case 'resizeNode':
this.handleResizeNode(operation as ResizeNodeOperation, change)
break
case 'setNodeZIndex':
this.handleSetNodeZIndex(operation as SetNodeZIndexOperation, change)
break
case 'createNode':
this.handleCreateNode(operation as CreateNodeOperation, change)
break
case 'deleteNode':
this.handleDeleteNode(operation as DeleteNodeOperation, change)
break
}
}
/**
* Finalize operation after transaction
*/
private finalizeOperation(change: LayoutChange): void {
// Update version
this.version++
// 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()
this.spatialIndex.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))
// Add to spatial index
this.spatialIndex.insert(layout.id, layout.bounds)
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: MoveNodeOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(operation.nodeId)
if (!ynode) {
logger.warn(`No ynode found for ${operation.nodeId}`)
return
}
logger.debug(`Moving node ${operation.nodeId}`, operation.position)
const size = ynode.get('size') as { width: number; height: number }
ynode.set('position', operation.position)
this.updateNodeBounds(ynode, operation.position, size)
// Update spatial index
this.spatialIndex.update(operation.nodeId, {
x: operation.position.x,
y: operation.position.y,
width: size.width,
height: size.height
})
change.nodeIds.push(operation.nodeId)
}
private handleResizeNode(
operation: ResizeNodeOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(operation.nodeId)
if (!ynode) return
const position = ynode.get('position') as Point
ynode.set('size', operation.size)
this.updateNodeBounds(ynode, position, operation.size)
// Update spatial index
this.spatialIndex.update(operation.nodeId, {
x: position.x,
y: position.y,
width: operation.size.width,
height: operation.size.height
})
change.nodeIds.push(operation.nodeId)
}
private handleSetNodeZIndex(
operation: SetNodeZIndexOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(operation.nodeId)
if (!ynode) return
ynode.set('zIndex', operation.zIndex)
change.nodeIds.push(operation.nodeId)
}
private handleCreateNode(
operation: CreateNodeOperation,
change: LayoutChange
): void {
const ynode = this.layoutToYNode(operation.layout)
this.ynodes.set(operation.nodeId, ynode)
// Add to spatial index
this.spatialIndex.insert(operation.nodeId, operation.layout.bounds)
change.type = 'create'
change.nodeIds.push(operation.nodeId)
}
private handleDeleteNode(
operation: DeleteNodeOperation,
change: LayoutChange
): void {
if (!this.ynodes.has(operation.nodeId)) return
this.ynodes.delete(operation.nodeId)
this.nodeRefs.delete(operation.nodeId)
this.nodeTriggers.delete(operation.nodeId)
// Remove from spatial index
this.spatialIndex.remove(operation.nodeId)
change.type = 'delete'
change.nodeIds.push(operation.nodeId)
}
/**
* Update node bounds helper
*/
private updateNodeBounds(
ynode: Y.Map<unknown>,
position: Point,
size: { width: number; height: number }
): void {
ynode.set('bounds', {
x: position.x,
y: position.y,
width: size.width,
height: size.height
})
}
// Helper methods
private layoutToYNode(layout: NodeLayout): Y.Map<unknown> {
const ynode = new Y.Map<unknown>()
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<unknown>): 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): LayoutOperation[] {
const operations: LayoutOperation[] = []
this.yoperations.forEach((op) => {
if (op && op.timestamp > timestamp) {
operations.push(op)
}
})
return operations
}
getOperationsByActor(actor: string): LayoutOperation[] {
const operations: LayoutOperation[] = []
this.yoperations.forEach((op) => {
if (op && op.actor === actor) {
operations.push(op)
}
})
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'

View File

@@ -0,0 +1,168 @@
/**
* Layout Operation Types
*
* Defines the operation interface for the CRDT-based layout system.
* Each operation is immutable and contains all information needed for:
* - Application (forward)
* - Undo/redo (reverse)
* - Conflict resolution (CRDT)
* - Debugging (actor, timestamp, source)
*/
import type { NodeId, NodeLayout, Point } from './layoutTypes'
/**
* Base operation interface that all operations extend
*/
export interface BaseOperation {
/** Unique operation ID for deduplication */
id?: string
/** Timestamp for ordering operations */
timestamp: number
/** Actor who performed the operation (for CRDT) */
actor: string
/** Source system that initiated the operation */
source: 'canvas' | 'vue' | 'external'
/** Node this operation affects */
nodeId: NodeId
}
/**
* Operation type discriminator for type narrowing
*/
export type OperationType =
| 'moveNode'
| 'resizeNode'
| 'setNodeZIndex'
| 'createNode'
| 'deleteNode'
| 'setNodeVisibility'
| 'batchUpdate'
/**
* Move node operation
*/
export interface MoveNodeOperation extends BaseOperation {
type: 'moveNode'
position: Point
previousPosition: Point
}
/**
* Resize node operation
*/
export interface ResizeNodeOperation extends BaseOperation {
type: 'resizeNode'
size: { width: number; height: number }
previousSize: { width: number; height: number }
}
/**
* Set node z-index operation
*/
export interface SetNodeZIndexOperation extends BaseOperation {
type: 'setNodeZIndex'
zIndex: number
previousZIndex: number
}
/**
* Create node operation
*/
export interface CreateNodeOperation extends BaseOperation {
type: 'createNode'
layout: NodeLayout
}
/**
* Delete node operation
*/
export interface DeleteNodeOperation extends BaseOperation {
type: 'deleteNode'
previousLayout: NodeLayout
}
/**
* Set node visibility operation
*/
export interface SetNodeVisibilityOperation extends BaseOperation {
type: 'setNodeVisibility'
visible: boolean
previousVisible: boolean
}
/**
* Batch update operation for atomic multi-property changes
*/
export interface BatchUpdateOperation extends BaseOperation {
type: 'batchUpdate'
updates: Partial<NodeLayout>
previousValues: Partial<NodeLayout>
}
/**
* Union of all operation types
*/
export type LayoutOperation =
| MoveNodeOperation
| ResizeNodeOperation
| SetNodeZIndexOperation
| CreateNodeOperation
| DeleteNodeOperation
| SetNodeVisibilityOperation
| BatchUpdateOperation
/**
* Type guards for operations
*/
export const isBaseOperation = (op: unknown): op is BaseOperation => {
return (
typeof op === 'object' &&
op !== null &&
'timestamp' in op &&
'actor' in op &&
'source' in op &&
'nodeId' in op
)
}
export const isMoveNodeOperation = (
op: LayoutOperation
): op is MoveNodeOperation => op.type === 'moveNode'
export const isResizeNodeOperation = (
op: LayoutOperation
): op is ResizeNodeOperation => op.type === 'resizeNode'
export const isCreateNodeOperation = (
op: LayoutOperation
): op is CreateNodeOperation => op.type === 'createNode'
export const isDeleteNodeOperation = (
op: LayoutOperation
): op is DeleteNodeOperation => op.type === 'deleteNode'
/**
* Operation application interface
*/
export interface OperationApplicator<
T extends LayoutOperation = LayoutOperation
> {
canApply(operation: T): boolean
apply(operation: T): void
reverse(operation: T): void
}
/**
* Operation serialization for network/storage
*/
export interface OperationSerializer {
serialize(operation: LayoutOperation): string
deserialize(data: string): LayoutOperation
}
/**
* Conflict resolution strategy
*/
export interface ConflictResolver {
resolve(op1: LayoutOperation, op2: LayoutOperation): LayoutOperation[]
}

204
src/types/layoutTypes.ts Normal file
View File

@@ -0,0 +1,204 @@
/**
* 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'
import type { LayoutOperation } from './layoutOperations'
// 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: LayoutOperation
}
// Store interfaces
export interface LayoutStore {
// CustomRef accessors for shared write access
getNodeLayoutRef(nodeId: NodeId): Ref<NodeLayout | null>
getNodesInBounds(bounds: Bounds): ComputedRef<NodeId[]>
getAllNodes(): ComputedRef<ReadonlyMap<NodeId, NodeLayout>>
getVersion(): ComputedRef<number>
// Spatial queries (non-reactive)
queryNodeAtPoint(point: Point): NodeId | null
queryNodesInBounds(bounds: Bounds): NodeId[]
// Direct mutation API (CRDT-ready)
applyOperation(operation: LayoutOperation): 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
}
// Re-export operation types from dedicated operations file
export type {
LayoutOperation as AnyLayoutOperation,
BaseOperation,
MoveNodeOperation,
ResizeNodeOperation,
SetNodeZIndexOperation,
CreateNodeOperation,
DeleteNodeOperation,
SetNodeVisibilityOperation,
BatchUpdateOperation,
OperationType,
OperationApplicator,
OperationSerializer,
ConflictResolver
} from './layoutOperations'
// 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<NodeLayout>): 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: LayoutOperation[]
addOperation(operation: LayoutOperation): void
getOperationsSince(timestamp: number): LayoutOperation[]
getOperationsByActor(actor: string): LayoutOperation[]
}