mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
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>
This commit is contained in:
@@ -131,7 +131,8 @@ import type {
|
||||
NodeState,
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useLayout, useLayoutSync } from '@/composables/graph/useLayout'
|
||||
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'
|
||||
|
||||
@@ -92,7 +92,7 @@ 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 { useNodeLayout } from '@/composables/graph/useNodeLayout'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
|
||||
import { LiteGraph } from '../../../lib/litegraph/src/litegraph'
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
/**
|
||||
* Composable for integrating Vue components with the Layout system
|
||||
* Main composable for accessing the layout system
|
||||
*
|
||||
* Uses customRef for shared write access and provides clean mutation API.
|
||||
* CRDT-ready with operation tracking.
|
||||
* Provides unified access to the layout store and mutation API.
|
||||
*/
|
||||
import log from 'loglevel'
|
||||
import { computed, inject, onUnmounted } from 'vue'
|
||||
|
||||
import { layoutMutations } from '@/services/layoutMutations'
|
||||
import { layoutStore } from '@/stores/layoutStore'
|
||||
import type { Bounds, NodeId, Point } from '@/types/layoutTypes'
|
||||
|
||||
// Create a logger for layout debugging
|
||||
const logger = log.getLogger('layout')
|
||||
// In dev mode, always show debug logs
|
||||
if (import.meta.env.DEV) {
|
||||
logger.setLevel('debug')
|
||||
}
|
||||
|
||||
/**
|
||||
* Main composable for accessing the layout system
|
||||
*/
|
||||
@@ -40,264 +29,3 @@ export function useLayout() {
|
||||
layoutStore.queryNodesInBounds(bounds)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for individual Vue node components
|
||||
* Uses customRef for shared write access with Canvas renderer
|
||||
*/
|
||||
export function useNodeLayout(nodeId: string) {
|
||||
const { store, mutations } = useLayout()
|
||||
|
||||
// Get transform utilities from TransformPane if available
|
||||
const transformState = inject('transformState') as
|
||||
| {
|
||||
canvasToScreen: (point: Point) => Point
|
||||
screenToCanvas: (point: Point) => Point
|
||||
}
|
||||
| undefined
|
||||
|
||||
// Get the customRef for this node (shared write access)
|
||||
const layoutRef = store.getNodeLayoutRef(nodeId)
|
||||
|
||||
logger.debug(`useNodeLayout initialized for node ${nodeId}`, {
|
||||
hasLayout: !!layoutRef.value,
|
||||
initialPosition: layoutRef.value?.position
|
||||
})
|
||||
|
||||
// Computed properties for easy access
|
||||
const position = computed(() => {
|
||||
const layout = layoutRef.value
|
||||
const pos = layout?.position ?? { x: 0, y: 0 }
|
||||
logger.debug(`Node ${nodeId} position computed:`, {
|
||||
pos,
|
||||
hasLayout: !!layout,
|
||||
layoutRefValue: layout
|
||||
})
|
||||
return pos
|
||||
})
|
||||
const size = computed(
|
||||
() => layoutRef.value?.size ?? { width: 200, height: 100 }
|
||||
)
|
||||
const bounds = computed(
|
||||
() =>
|
||||
layoutRef.value?.bounds ?? {
|
||||
x: position.value.x,
|
||||
y: position.value.y,
|
||||
width: size.value.width,
|
||||
height: size.value.height
|
||||
}
|
||||
)
|
||||
const isVisible = computed(() => layoutRef.value?.visible ?? true)
|
||||
const zIndex = computed(() => layoutRef.value?.zIndex ?? 0)
|
||||
|
||||
// Drag state
|
||||
let isDragging = false
|
||||
let dragStartPos: Point | null = null
|
||||
let dragStartMouse: Point | null = null
|
||||
|
||||
/**
|
||||
* Start dragging the node
|
||||
*/
|
||||
function startDrag(event: PointerEvent) {
|
||||
if (!layoutRef.value) return
|
||||
|
||||
isDragging = true
|
||||
dragStartPos = { ...position.value }
|
||||
dragStartMouse = { x: event.clientX, y: event.clientY }
|
||||
|
||||
// Set mutation source
|
||||
mutations.setSource('vue')
|
||||
|
||||
// Capture pointer
|
||||
const target = event.target as HTMLElement
|
||||
target.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag movement
|
||||
*/
|
||||
const handleDrag = (event: PointerEvent) => {
|
||||
if (!isDragging || !dragStartPos || !dragStartMouse || !transformState) {
|
||||
logger.debug(`Drag skipped for node ${nodeId}:`, {
|
||||
isDragging,
|
||||
hasDragStartPos: !!dragStartPos,
|
||||
hasDragStartMouse: !!dragStartMouse,
|
||||
hasTransformState: !!transformState
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate mouse delta in screen coordinates
|
||||
const mouseDelta = {
|
||||
x: event.clientX - dragStartMouse.x,
|
||||
y: event.clientY - dragStartMouse.y
|
||||
}
|
||||
|
||||
// Convert to canvas coordinates
|
||||
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
|
||||
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
|
||||
const canvasDelta = {
|
||||
x: canvasWithDelta.x - canvasOrigin.x,
|
||||
y: canvasWithDelta.y - canvasOrigin.y
|
||||
}
|
||||
|
||||
// Calculate new position
|
||||
const newPosition = {
|
||||
x: dragStartPos.x + canvasDelta.x,
|
||||
y: dragStartPos.y + canvasDelta.y
|
||||
}
|
||||
|
||||
logger.debug(`Dragging node ${nodeId}:`, {
|
||||
mouseDelta,
|
||||
canvasDelta,
|
||||
newPosition,
|
||||
currentLayoutPos: layoutRef.value?.position
|
||||
})
|
||||
|
||||
// Apply mutation through the layout system
|
||||
mutations.moveNode(nodeId, newPosition)
|
||||
}
|
||||
|
||||
/**
|
||||
* End dragging
|
||||
*/
|
||||
function endDrag(event: PointerEvent) {
|
||||
if (!isDragging) return
|
||||
|
||||
isDragging = false
|
||||
dragStartPos = null
|
||||
dragStartMouse = null
|
||||
|
||||
// Release pointer
|
||||
const target = event.target as HTMLElement
|
||||
target.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node position directly (without drag)
|
||||
*/
|
||||
function moveTo(position: Point) {
|
||||
mutations.setSource('vue')
|
||||
mutations.moveNode(nodeId, position)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node size
|
||||
*/
|
||||
function resize(newSize: { width: number; height: number }) {
|
||||
mutations.setSource('vue')
|
||||
mutations.resizeNode(nodeId, newSize)
|
||||
}
|
||||
|
||||
return {
|
||||
// Reactive state (via customRef)
|
||||
layoutRef,
|
||||
position,
|
||||
size,
|
||||
bounds,
|
||||
isVisible,
|
||||
zIndex,
|
||||
|
||||
// Mutations
|
||||
moveTo,
|
||||
resize,
|
||||
|
||||
// Drag handlers
|
||||
startDrag,
|
||||
handleDrag,
|
||||
endDrag,
|
||||
|
||||
// Computed styles for Vue templates
|
||||
nodeStyle: computed(() => ({
|
||||
position: 'absolute' as const,
|
||||
left: `${position.value.x}px`,
|
||||
top: `${position.value.y}px`,
|
||||
width: `${size.value.width}px`,
|
||||
height: `${size.value.height}px`,
|
||||
zIndex: zIndex.value,
|
||||
cursor: isDragging ? 'grabbing' : 'grab'
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for syncing LiteGraph with the Layout system
|
||||
* This replaces the bidirectional sync with a one-way sync
|
||||
*/
|
||||
export function useLayoutSync() {
|
||||
const { store } = useLayout()
|
||||
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
/**
|
||||
* Start syncing from Layout system to LiteGraph
|
||||
* This is one-way: Layout → LiteGraph only
|
||||
*/
|
||||
function startSync(canvas: any) {
|
||||
if (!canvas?.graph) return
|
||||
|
||||
// Subscribe to layout changes
|
||||
unsubscribe = store.onChange((change) => {
|
||||
logger.debug('Layout sync received change:', {
|
||||
source: change.source,
|
||||
nodeIds: change.nodeIds,
|
||||
type: change.type
|
||||
})
|
||||
|
||||
// Apply changes to LiteGraph regardless of source
|
||||
// The layout store is the single source of truth
|
||||
for (const nodeId of change.nodeIds) {
|
||||
const layout = store.getNodeLayoutRef(nodeId).value
|
||||
if (!layout) continue
|
||||
|
||||
const liteNode = canvas.graph.getNodeById(parseInt(nodeId))
|
||||
if (!liteNode) continue
|
||||
|
||||
// Update position if changed
|
||||
if (
|
||||
liteNode.pos[0] !== layout.position.x ||
|
||||
liteNode.pos[1] !== layout.position.y
|
||||
) {
|
||||
logger.debug(`Updating LiteGraph node ${nodeId} position:`, {
|
||||
from: { x: liteNode.pos[0], y: liteNode.pos[1] },
|
||||
to: layout.position
|
||||
})
|
||||
liteNode.pos[0] = layout.position.x
|
||||
liteNode.pos[1] = layout.position.y
|
||||
}
|
||||
|
||||
// Update size if changed
|
||||
if (
|
||||
liteNode.size[0] !== layout.size.width ||
|
||||
liteNode.size[1] !== layout.size.height
|
||||
) {
|
||||
liteNode.size[0] = layout.size.width
|
||||
liteNode.size[1] = layout.size.height
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger single redraw for all changes
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop syncing
|
||||
*/
|
||||
function stopSync() {
|
||||
if (unsubscribe) {
|
||||
unsubscribe()
|
||||
unsubscribe = null
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
stopSync()
|
||||
})
|
||||
|
||||
return {
|
||||
startSync,
|
||||
stopSync
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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'
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,14 @@ import { type ComputedRef, type Ref, computed, customRef } from 'vue'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import type {
|
||||
AnyLayoutOperation,
|
||||
CreateNodeOperation,
|
||||
DeleteNodeOperation,
|
||||
LayoutOperation,
|
||||
MoveNodeOperation,
|
||||
ResizeNodeOperation,
|
||||
SetNodeZIndexOperation
|
||||
} from '@/types/layoutOperations'
|
||||
import type {
|
||||
Bounds,
|
||||
LayoutChange,
|
||||
LayoutStore,
|
||||
@@ -17,6 +24,7 @@ import type {
|
||||
NodeLayout,
|
||||
Point
|
||||
} from '@/types/layoutTypes'
|
||||
import { QuadTree } from '@/utils/spatial/QuadTree'
|
||||
|
||||
// Create logger for layout store
|
||||
const logger = log.getLogger('layout-store')
|
||||
@@ -29,7 +37,7 @@ 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<AnyLayoutOperation> // Operation log
|
||||
private yoperations: Y.Array<LayoutOperation> // Operation log
|
||||
|
||||
// Vue reactivity layer
|
||||
private version = 0
|
||||
@@ -43,7 +51,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
private nodeRefs = new Map<NodeId, Ref<NodeLayout | null>>()
|
||||
private nodeTriggers = new Map<NodeId, () => void>()
|
||||
|
||||
// Spatial index cache
|
||||
// Spatial index using existing QuadTree infrastructure
|
||||
private spatialIndex: QuadTree<NodeId>
|
||||
private spatialQueryCache = new Map<string, NodeId[]>()
|
||||
|
||||
constructor() {
|
||||
@@ -51,6 +60,12 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
this.ynodes = this.ydoc.getMap('nodes')
|
||||
this.yoperations = this.ydoc.getArray('operations')
|
||||
|
||||
// Initialize QuadTree with reasonable bounds
|
||||
this.spatialIndex = new QuadTree<NodeId>(
|
||||
{ x: -10000, y: -10000, width: 20000, height: 20000 },
|
||||
{ maxDepth: 6, maxItemsPerNode: 4 }
|
||||
)
|
||||
|
||||
// Listen for Yjs changes and trigger Vue reactivity
|
||||
this.ynodes.observe((event) => {
|
||||
this.version++
|
||||
@@ -69,11 +84,11 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
// Debug: Log layout operations
|
||||
if (localStorage.getItem('layout-debug') === 'true') {
|
||||
this.yoperations.observe((event) => {
|
||||
const operations: AnyLayoutOperation[] = []
|
||||
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 AnyLayoutOperation)
|
||||
operations.push(content[0] as LayoutOperation)
|
||||
}
|
||||
})
|
||||
console.log('Layout Operation:', operations)
|
||||
@@ -278,16 +293,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
const cached = this.spatialQueryCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const result: NodeId[] = []
|
||||
for (const [nodeId] of this.ynodes) {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (ynode) {
|
||||
const layout = this.yNodeToLayout(ynode)
|
||||
if (layout && this.boundsIntersect(layout.bounds, bounds)) {
|
||||
result.push(nodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Use QuadTree for efficient spatial query
|
||||
const result = this.spatialIndex.query(bounds)
|
||||
|
||||
// Cache result
|
||||
this.spatialQueryCache.set(cacheKey, result)
|
||||
@@ -297,7 +304,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
/**
|
||||
* Apply a layout operation using Yjs transactions
|
||||
*/
|
||||
applyOperation(operation: AnyLayoutOperation): void {
|
||||
applyOperation(operation: LayoutOperation): void {
|
||||
logger.debug(`applyOperation called:`, {
|
||||
type: operation.type,
|
||||
nodeId: operation.nodeId,
|
||||
@@ -318,29 +325,44 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
// Add operation to log
|
||||
this.yoperations.push([operation])
|
||||
|
||||
switch (operation.type) {
|
||||
case 'moveNode':
|
||||
this.handleMoveNode(operation, change)
|
||||
break
|
||||
// Apply the operation
|
||||
this.applyOperationInTransaction(operation, change)
|
||||
}, this.currentActor)
|
||||
|
||||
case 'resizeNode':
|
||||
this.handleResizeNode(operation, change)
|
||||
break
|
||||
// Post-transaction updates
|
||||
this.finalizeOperation(change)
|
||||
}
|
||||
|
||||
case 'setNodeZIndex':
|
||||
this.handleSetNodeZIndex(operation, change)
|
||||
break
|
||||
|
||||
case 'createNode':
|
||||
this.handleCreateNode(operation, change)
|
||||
break
|
||||
|
||||
case 'deleteNode':
|
||||
this.handleDeleteNode(operation, change)
|
||||
break
|
||||
}
|
||||
}, this.currentActor) // Use actor as transaction origin
|
||||
/**
|
||||
* 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 and clear cache
|
||||
this.version++
|
||||
this.spatialQueryCache.clear()
|
||||
@@ -412,6 +434,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
this.ynodes.clear()
|
||||
this.nodeRefs.clear()
|
||||
this.nodeTriggers.clear()
|
||||
this.spatialIndex.clear()
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
const layout: NodeLayout = {
|
||||
@@ -429,6 +452,10 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
}
|
||||
|
||||
this.ynodes.set(layout.id, this.layoutToYNode(layout))
|
||||
|
||||
// Add to spatial index
|
||||
this.spatialIndex.insert(layout.id, layout.bounds, layout.id)
|
||||
|
||||
logger.debug(
|
||||
`Initialized node ${layout.id} at position:`,
|
||||
layout.position
|
||||
@@ -443,30 +470,23 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
// Operation handlers
|
||||
private handleMoveNode(
|
||||
operation: AnyLayoutOperation,
|
||||
operation: MoveNodeOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
if (operation.type !== 'moveNode') return
|
||||
|
||||
logger.debug(`handleMoveNode called for ${operation.nodeId}`, {
|
||||
newPosition: operation.position
|
||||
})
|
||||
|
||||
const ynode = this.ynodes.get(operation.nodeId)
|
||||
if (!ynode) {
|
||||
logger.warn(`No ynode found for ${operation.nodeId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Update position in Yjs map
|
||||
ynode.set('position', {
|
||||
x: operation.position.x,
|
||||
y: operation.position.y
|
||||
})
|
||||
logger.debug(`Moving node ${operation.nodeId}`, operation.position)
|
||||
|
||||
// Update bounds
|
||||
const size = ynode.get('size') as { width: number; height: number }
|
||||
ynode.set('bounds', {
|
||||
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,
|
||||
@@ -477,23 +497,18 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
}
|
||||
|
||||
private handleResizeNode(
|
||||
operation: AnyLayoutOperation,
|
||||
operation: ResizeNodeOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
if (operation.type !== 'resizeNode') return
|
||||
|
||||
const ynode = this.ynodes.get(operation.nodeId)
|
||||
if (!ynode) return
|
||||
|
||||
// Update size in Yjs map
|
||||
ynode.set('size', {
|
||||
width: operation.size.width,
|
||||
height: operation.size.height
|
||||
})
|
||||
|
||||
// Update bounds
|
||||
const position = ynode.get('position') as Point
|
||||
ynode.set('bounds', {
|
||||
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,
|
||||
@@ -504,11 +519,9 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
}
|
||||
|
||||
private handleSetNodeZIndex(
|
||||
operation: AnyLayoutOperation,
|
||||
operation: SetNodeZIndexOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
if (operation.type !== 'setNodeZIndex') return
|
||||
|
||||
const ynode = this.ynodes.get(operation.nodeId)
|
||||
if (!ynode) return
|
||||
|
||||
@@ -517,33 +530,54 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
}
|
||||
|
||||
private handleCreateNode(
|
||||
operation: AnyLayoutOperation,
|
||||
operation: CreateNodeOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
if (operation.type !== 'createNode') return
|
||||
|
||||
const ynode = this.layoutToYNode(operation.layout)
|
||||
this.ynodes.set(operation.nodeId, ynode)
|
||||
|
||||
// Add to spatial index
|
||||
this.spatialIndex.insert(
|
||||
operation.nodeId,
|
||||
operation.layout.bounds,
|
||||
operation.nodeId
|
||||
)
|
||||
|
||||
change.type = 'create'
|
||||
change.nodeIds.push(operation.nodeId)
|
||||
}
|
||||
|
||||
private handleDeleteNode(
|
||||
operation: AnyLayoutOperation,
|
||||
operation: DeleteNodeOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
if (operation.type !== 'deleteNode') return
|
||||
if (!this.ynodes.has(operation.nodeId)) return
|
||||
|
||||
const hadNode = this.ynodes.has(operation.nodeId)
|
||||
this.ynodes.delete(operation.nodeId)
|
||||
this.nodeRefs.delete(operation.nodeId)
|
||||
this.nodeTriggers.delete(operation.nodeId)
|
||||
|
||||
if (hadNode) {
|
||||
this.nodeRefs.delete(operation.nodeId)
|
||||
this.nodeTriggers.delete(operation.nodeId)
|
||||
change.type = 'delete'
|
||||
change.nodeIds.push(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
|
||||
@@ -598,21 +632,21 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
}
|
||||
|
||||
// CRDT-specific methods
|
||||
getOperationsSince(timestamp: number): AnyLayoutOperation[] {
|
||||
const operations: AnyLayoutOperation[] = []
|
||||
getOperationsSince(timestamp: number): LayoutOperation[] {
|
||||
const operations: LayoutOperation[] = []
|
||||
this.yoperations.forEach((op) => {
|
||||
if (op && (op as AnyLayoutOperation).timestamp > timestamp) {
|
||||
operations.push(op as AnyLayoutOperation)
|
||||
if (op && op.timestamp > timestamp) {
|
||||
operations.push(op)
|
||||
}
|
||||
})
|
||||
return operations
|
||||
}
|
||||
|
||||
getOperationsByActor(actor: string): AnyLayoutOperation[] {
|
||||
const operations: AnyLayoutOperation[] = []
|
||||
getOperationsByActor(actor: string): LayoutOperation[] {
|
||||
const operations: LayoutOperation[] = []
|
||||
this.yoperations.forEach((op) => {
|
||||
if (op && (op as AnyLayoutOperation).actor === actor) {
|
||||
operations.push(op as AnyLayoutOperation)
|
||||
if (op && op.actor === actor) {
|
||||
operations.push(op)
|
||||
}
|
||||
})
|
||||
return operations
|
||||
|
||||
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[]
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
*/
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { LayoutOperation } from './layoutOperations'
|
||||
|
||||
// Basic geometric types
|
||||
export interface Point {
|
||||
x: number
|
||||
@@ -124,7 +126,7 @@ export interface LayoutChange {
|
||||
nodeIds: NodeId[]
|
||||
timestamp: number
|
||||
source: 'canvas' | 'vue' | 'external'
|
||||
operation: AnyLayoutOperation
|
||||
operation: LayoutOperation
|
||||
}
|
||||
|
||||
// Store interfaces
|
||||
@@ -140,7 +142,7 @@ export interface LayoutStore {
|
||||
queryNodesInBounds(bounds: Bounds): NodeId[]
|
||||
|
||||
// Direct mutation API (CRDT-ready)
|
||||
applyOperation(operation: AnyLayoutOperation): void
|
||||
applyOperation(operation: LayoutOperation): void
|
||||
|
||||
// Change subscription
|
||||
onChange(callback: (change: LayoutChange) => void): () => void
|
||||
@@ -157,54 +159,22 @@ export interface LayoutStore {
|
||||
getCurrentActor(): string
|
||||
}
|
||||
|
||||
// Operation tracking for CRDT compatibility
|
||||
export interface LayoutOperation {
|
||||
type: LayoutMutationType
|
||||
nodeId?: NodeId
|
||||
timestamp: number
|
||||
source: 'canvas' | 'vue' | 'external'
|
||||
actor?: string // For CRDT - identifies who made the change
|
||||
}
|
||||
|
||||
export interface MoveOperation extends LayoutOperation {
|
||||
type: 'moveNode'
|
||||
nodeId: NodeId
|
||||
position: Point
|
||||
previousPosition?: Point
|
||||
}
|
||||
|
||||
export interface ResizeOperation extends LayoutOperation {
|
||||
type: 'resizeNode'
|
||||
nodeId: NodeId
|
||||
size: Size
|
||||
previousSize?: Size
|
||||
}
|
||||
|
||||
export interface CreateOperation extends LayoutOperation {
|
||||
type: 'createNode'
|
||||
nodeId: NodeId
|
||||
layout: NodeLayout
|
||||
}
|
||||
|
||||
export interface DeleteOperation extends LayoutOperation {
|
||||
type: 'deleteNode'
|
||||
nodeId: NodeId
|
||||
previousLayout?: NodeLayout
|
||||
}
|
||||
|
||||
export interface ZIndexOperation extends LayoutOperation {
|
||||
type: 'setNodeZIndex'
|
||||
nodeId: NodeId
|
||||
zIndex: number
|
||||
previousZIndex?: number
|
||||
}
|
||||
|
||||
export type AnyLayoutOperation =
|
||||
| MoveOperation
|
||||
| ResizeOperation
|
||||
| CreateOperation
|
||||
| DeleteOperation
|
||||
| ZIndexOperation
|
||||
// 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 {
|
||||
@@ -227,8 +197,8 @@ export interface LayoutMutations {
|
||||
|
||||
// CRDT-ready operation log (for future CRDT integration)
|
||||
export interface OperationLog {
|
||||
operations: AnyLayoutOperation[]
|
||||
addOperation(operation: AnyLayoutOperation): void
|
||||
getOperationsSince(timestamp: number): AnyLayoutOperation[]
|
||||
getOperationsByActor(actor: string): AnyLayoutOperation[]
|
||||
operations: LayoutOperation[]
|
||||
addOperation(operation: LayoutOperation): void
|
||||
getOperationsSince(timestamp: number): LayoutOperation[]
|
||||
getOperationsByActor(actor: string): LayoutOperation[]
|
||||
}
|
||||
|
||||
@@ -154,13 +154,13 @@ describe('layoutStore CRDT operations', () => {
|
||||
})
|
||||
|
||||
// Wait for async notification
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(changes.length).toBeGreaterThanOrEqual(1)
|
||||
const lastChange = changes[changes.length - 1]
|
||||
expect(lastChange.source).toBe('vue')
|
||||
expect(lastChange.operation.actor).toBe('user-123')
|
||||
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
@@ -229,6 +229,7 @@ describe('layoutStore CRDT operations', () => {
|
||||
type: 'moveNode',
|
||||
nodeId,
|
||||
position: { x: 150, y: 150 },
|
||||
previousPosition: { x: 100, y: 100 },
|
||||
timestamp: startTime + 100,
|
||||
source: 'vue',
|
||||
actor: 'test-actor'
|
||||
@@ -245,4 +246,4 @@ describe('layoutStore CRDT operations', () => {
|
||||
expect(recentOps.length).toBeGreaterThanOrEqual(1)
|
||||
expect(recentOps[0].type).toBe('moveNode')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user