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>
This commit is contained in:
bymyself
2025-08-13 00:26:00 -07:00
parent 7c7263f2cd
commit 43d9678e68
15 changed files with 2336 additions and 272 deletions

View File

@@ -131,6 +131,7 @@ import type {
NodeState,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { useLayoutSync } from '@/composables/graph/useLayout'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
@@ -155,6 +156,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'
@@ -311,6 +313,18 @@ const initializeNodeManager = () => {
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++
}
@@ -465,6 +479,14 @@ 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) {
const { mutations } = useLayout()
mutations.setSource('vue')
mutations.bringNodeToFront(nodeData.id)
}
node.selected = true
canvasStore.updateSelectedItems()

View File

@@ -5,6 +5,7 @@
</div>
<div
v-else
:data-node-id="nodeData.id"
:class="[
'lg-node absolute border-2 rounded-lg',
'contain-layout contain-style contain-paint',
@@ -13,15 +14,22 @@
nodeData.mode === 4 ? 'opacity-50' : '', // bypassed
error ? 'border-red-500 bg-red-50' : '',
isDragging ? 'will-change-transform' : '',
lodCssClass
lodCssClass,
'hover:border-green-500' // Debug: visual feedback on hover
]"
:style="[
{
transform: `translate(${layoutPosition.x ?? position?.x ?? 0}px, ${(layoutPosition.y ?? position?.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
width: size ? `${size.width}px` : '200px',
height: size ? `${size.height}px` : 'auto',
backgroundColor: '#353535',
pointerEvents: 'auto'
},
dragStyle
]"
:style="{
transform: `translate(${position?.x ?? 0}px, ${(position?.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
width: size ? `${size.width}px` : '200px',
height: size ? `${size.height}px` : 'auto',
backgroundColor: '#353535'
}"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
>
<!-- Header only updates on title/color changes -->
<NodeHeader
@@ -78,11 +86,13 @@
</template>
<script setup lang="ts">
import log from 'loglevel'
import { computed, onErrorCaptured, ref, toRef, watch } from 'vue'
// Import the VueNodeData type
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { LODLevel, useLOD } from '@/composables/graph/useLOD'
import { useNodeLayout } from '@/composables/graph/useLayout'
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>