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:
bymyself
2025-08-13 01:38:09 -07:00
parent ca43f90c93
commit 4ea9ec9e4b
9 changed files with 616 additions and 418 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,97 @@
/**
* Composable for syncing LiteGraph with the Layout system
*
* Implements one-way sync from Layout Store to LiteGraph.
* The layout store is the single source of truth.
*/
import log from 'loglevel'
import { onUnmounted } from 'vue'
import { layoutStore } from '@/stores/layoutStore'
// Create a logger for layout debugging
const logger = log.getLogger('layout')
// In dev mode, always show debug logs
if (import.meta.env.DEV) {
logger.setLevel('debug')
}
/**
* Composable for syncing LiteGraph with the Layout system
* This replaces the bidirectional sync with a one-way sync
*/
export function useLayoutSync() {
let unsubscribe: (() => void) | null = null
/**
* Start syncing from Layout system to LiteGraph
* This is one-way: Layout → LiteGraph only
*/
function startSync(canvas: any) {
if (!canvas?.graph) return
// Subscribe to layout changes
unsubscribe = layoutStore.onChange((change) => {
logger.debug('Layout sync received change:', {
source: change.source,
nodeIds: change.nodeIds,
type: change.type
})
// Apply changes to LiteGraph regardless of source
// The layout store is the single source of truth
for (const nodeId of change.nodeIds) {
const layout = layoutStore.getNodeLayoutRef(nodeId).value
if (!layout) continue
const liteNode = canvas.graph.getNodeById(parseInt(nodeId))
if (!liteNode) continue
// Update position if changed
if (
liteNode.pos[0] !== layout.position.x ||
liteNode.pos[1] !== layout.position.y
) {
logger.debug(`Updating LiteGraph node ${nodeId} position:`, {
from: { x: liteNode.pos[0], y: liteNode.pos[1] },
to: layout.position
})
liteNode.pos[0] = layout.position.x
liteNode.pos[1] = layout.position.y
}
// Update size if changed
if (
liteNode.size[0] !== layout.size.width ||
liteNode.size[1] !== layout.size.height
) {
liteNode.size[0] = layout.size.width
liteNode.size[1] = layout.size.height
}
}
// Trigger single redraw for all changes
canvas.setDirty(true, true)
})
}
/**
* Stop syncing
*/
function stopSync() {
if (unsubscribe) {
unsubscribe()
unsubscribe = null
}
}
// Auto-cleanup on unmount
onUnmounted(() => {
stopSync()
})
return {
startSync,
stopSync
}
}

View File

@@ -0,0 +1,199 @@
/**
* Composable for individual Vue node components
*
* Uses customRef for shared write access with Canvas renderer.
* Provides dragging functionality and reactive layout state.
*/
import log from 'loglevel'
import { computed, inject } from 'vue'
import { layoutMutations } from '@/services/layoutMutations'
import { layoutStore } from '@/stores/layoutStore'
import type { Point } from '@/types/layoutTypes'
// Create a logger for layout debugging
const logger = log.getLogger('layout')
// In dev mode, always show debug logs
if (import.meta.env.DEV) {
logger.setLevel('debug')
}
/**
* Composable for individual Vue node components
* Uses customRef for shared write access with Canvas renderer
*/
export function useNodeLayout(nodeId: string) {
const store = layoutStore
const mutations = layoutMutations
// Get transform utilities from TransformPane if available
const transformState = inject('transformState') as
| {
canvasToScreen: (point: Point) => Point
screenToCanvas: (point: Point) => Point
}
| undefined
// Get the customRef for this node (shared write access)
const layoutRef = store.getNodeLayoutRef(nodeId)
logger.debug(`useNodeLayout initialized for node ${nodeId}`, {
hasLayout: !!layoutRef.value,
initialPosition: layoutRef.value?.position
})
// Computed properties for easy access
const position = computed(() => {
const layout = layoutRef.value
const pos = layout?.position ?? { x: 0, y: 0 }
logger.debug(`Node ${nodeId} position computed:`, {
pos,
hasLayout: !!layout,
layoutRefValue: layout
})
return pos
})
const size = computed(
() => layoutRef.value?.size ?? { width: 200, height: 100 }
)
const bounds = computed(
() =>
layoutRef.value?.bounds ?? {
x: position.value.x,
y: position.value.y,
width: size.value.width,
height: size.value.height
}
)
const isVisible = computed(() => layoutRef.value?.visible ?? true)
const zIndex = computed(() => layoutRef.value?.zIndex ?? 0)
// Drag state
let isDragging = false
let dragStartPos: Point | null = null
let dragStartMouse: Point | null = null
/**
* Start dragging the node
*/
function startDrag(event: PointerEvent) {
if (!layoutRef.value) return
isDragging = true
dragStartPos = { ...position.value }
dragStartMouse = { x: event.clientX, y: event.clientY }
// Set mutation source
mutations.setSource('vue')
// Capture pointer
const target = event.target as HTMLElement
target.setPointerCapture(event.pointerId)
}
/**
* Handle drag movement
*/
const handleDrag = (event: PointerEvent) => {
if (!isDragging || !dragStartPos || !dragStartMouse || !transformState) {
logger.debug(`Drag skipped for node ${nodeId}:`, {
isDragging,
hasDragStartPos: !!dragStartPos,
hasDragStartMouse: !!dragStartMouse,
hasTransformState: !!transformState
})
return
}
// Calculate mouse delta in screen coordinates
const mouseDelta = {
x: event.clientX - dragStartMouse.x,
y: event.clientY - dragStartMouse.y
}
// Convert to canvas coordinates
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
const canvasDelta = {
x: canvasWithDelta.x - canvasOrigin.x,
y: canvasWithDelta.y - canvasOrigin.y
}
// Calculate new position
const newPosition = {
x: dragStartPos.x + canvasDelta.x,
y: dragStartPos.y + canvasDelta.y
}
logger.debug(`Dragging node ${nodeId}:`, {
mouseDelta,
canvasDelta,
newPosition,
currentLayoutPos: layoutRef.value?.position
})
// Apply mutation through the layout system
mutations.moveNode(nodeId, newPosition)
}
/**
* End dragging
*/
function endDrag(event: PointerEvent) {
if (!isDragging) return
isDragging = false
dragStartPos = null
dragStartMouse = null
// Release pointer
const target = event.target as HTMLElement
target.releasePointerCapture(event.pointerId)
}
/**
* Update node position directly (without drag)
*/
function moveTo(position: Point) {
mutations.setSource('vue')
mutations.moveNode(nodeId, position)
}
/**
* Update node size
*/
function resize(newSize: { width: number; height: number }) {
mutations.setSource('vue')
mutations.resizeNode(nodeId, newSize)
}
return {
// Reactive state (via customRef)
layoutRef,
position,
size,
bounds,
isVisible,
zIndex,
// Mutations
moveTo,
resize,
// Drag handlers
startDrag,
handleDrag,
endDrag,
// Computed styles for Vue templates
nodeStyle: computed(() => ({
position: 'absolute' as const,
left: `${position.value.x}px`,
top: `${position.value.y}px`,
width: `${size.value.width}px`,
height: `${size.value.height}px`,
zIndex: zIndex.value,
cursor: isDragging ? 'grabbing' : 'grab'
}))
}
}

View File

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

View File

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

View File

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

View File

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