mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-09 07:00:06 +00:00
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 commit460493a620. * Remove slots from layoutTypes * Totally not scuffed renderer and adapter * Revert "Totally not scuffed renderer and adapter" This reverts commit2b9d83efb8. * Revert "Remove slots from layoutTypes" This reverts commit18f78ff786. * Reapply "Add node slots to layout tree" This reverts commit236fecb549. * Revert "Add node slots to layout tree" This reverts commit460493a620. * 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:
82
src/adapters/layoutAdapter.ts
Normal file
82
src/adapters/layoutAdapter.ts
Normal 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
|
||||
}
|
||||
137
src/adapters/mockLayoutAdapter.ts
Normal file
137
src/adapters/mockLayoutAdapter.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
202
src/adapters/yjsLayoutAdapter.ts
Normal file
202
src/adapters/yjsLayoutAdapter.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
211
src/composables/graph/README.md
Normal file
211
src/composables/graph/README.md
Normal 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
|
||||
@@ -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)
|
||||
|
||||
31
src/composables/graph/useLayout.ts
Normal file
31
src/composables/graph/useLayout.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
97
src/composables/graph/useLayoutSync.ts
Normal file
97
src/composables/graph/useLayoutSync.ts
Normal 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
|
||||
}
|
||||
}
|
||||
180
src/composables/graph/useNodeChangeDetection.ts
Normal file
180
src/composables/graph/useNodeChangeDetection.ts
Normal 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
|
||||
}
|
||||
}
|
||||
199
src/composables/graph/useNodeLayout.ts
Normal file
199
src/composables/graph/useNodeLayout.ts
Normal 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'
|
||||
}))
|
||||
}
|
||||
}
|
||||
260
src/composables/graph/useNodeState.ts
Normal file
260
src/composables/graph/useNodeState.ts
Normal 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
|
||||
}
|
||||
}
|
||||
182
src/composables/graph/useNodeWidgets.ts
Normal file
182
src/composables/graph/useNodeWidgets.ts
Normal 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
73
src/constants/layout.ts
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
150
src/services/layoutMutations.ts
Normal file
150
src/services/layoutMutations.ts
Normal 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()
|
||||
166
src/services/spatialIndexManager.ts
Normal file
166
src/services/spatialIndexManager.ts
Normal 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
665
src/stores/layoutStore.ts
Normal 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'
|
||||
168
src/types/layoutOperations.ts
Normal file
168
src/types/layoutOperations.ts
Normal 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
204
src/types/layoutTypes.ts
Normal 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[]
|
||||
}
|
||||
Reference in New Issue
Block a user