diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index ebbe21a50..4b962f407 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -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' diff --git a/src/components/graph/vueNodes/LGraphNode.vue b/src/components/graph/vueNodes/LGraphNode.vue index 94b8c93ed..3aa80a694 100644 --- a/src/components/graph/vueNodes/LGraphNode.vue +++ b/src/components/graph/vueNodes/LGraphNode.vue @@ -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' diff --git a/src/composables/graph/useLayout.ts b/src/composables/graph/useLayout.ts index c01315157..0199a8db2 100644 --- a/src/composables/graph/useLayout.ts +++ b/src/composables/graph/useLayout.ts @@ -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 - } -} diff --git a/src/composables/graph/useLayoutSync.ts b/src/composables/graph/useLayoutSync.ts new file mode 100644 index 000000000..4d9e113cf --- /dev/null +++ b/src/composables/graph/useLayoutSync.ts @@ -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 + } +} diff --git a/src/composables/graph/useNodeLayout.ts b/src/composables/graph/useNodeLayout.ts new file mode 100644 index 000000000..05cc46751 --- /dev/null +++ b/src/composables/graph/useNodeLayout.ts @@ -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' + })) + } +} diff --git a/src/stores/layoutStore.ts b/src/stores/layoutStore.ts index 1a188d1e8..0f13d7099 100644 --- a/src/stores/layoutStore.ts +++ b/src/stores/layoutStore.ts @@ -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> // Maps nodeId -> Y.Map containing NodeLayout data - private yoperations: Y.Array // Operation log + private yoperations: Y.Array // Operation log // Vue reactivity layer private version = 0 @@ -43,7 +51,8 @@ class LayoutStoreImpl implements LayoutStore { private nodeRefs = new Map>() private nodeTriggers = new Map void>() - // Spatial index cache + // Spatial index using existing QuadTree infrastructure + private spatialIndex: QuadTree private spatialQueryCache = new Map() 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( + { 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, + 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 diff --git a/src/types/layoutOperations.ts b/src/types/layoutOperations.ts new file mode 100644 index 000000000..23fe18158 --- /dev/null +++ b/src/types/layoutOperations.ts @@ -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 + previousValues: Partial +} + +/** + * 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[] +} diff --git a/src/types/layoutTypes.ts b/src/types/layoutTypes.ts index 689720dca..541874bb9 100644 --- a/src/types/layoutTypes.ts +++ b/src/types/layoutTypes.ts @@ -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[] } diff --git a/tests-ui/tests/stores/layoutStore.test.ts b/tests-ui/tests/stores/layoutStore.test.ts index adca96eaf..93ad887d2 100644 --- a/tests-ui/tests/stores/layoutStore.test.ts +++ b/tests-ui/tests/stores/layoutStore.test.ts @@ -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') }) -}) \ No newline at end of file +})