From 460493a6203d0d7c15422fb4fd2f021eb6809e37 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 14 Aug 2025 12:22:10 -0400 Subject: [PATCH] Add node slots to layout tree --- src/stores/layoutStore.ts | 321 +++++++++++++++++++++++++++++++++- src/types/layoutOperations.ts | 82 ++++++++- src/types/layoutTypes.ts | 15 +- 3 files changed, 413 insertions(+), 5 deletions(-) diff --git a/src/stores/layoutStore.ts b/src/stores/layoutStore.ts index 039ee1c95..94350467e 100644 --- a/src/stores/layoutStore.ts +++ b/src/stores/layoutStore.ts @@ -11,12 +11,16 @@ import * as Y from 'yjs' import { ACTOR_CONFIG, DEBUG_CONFIG } from '@/constants/layout' import { SpatialIndexManager } from '@/services/spatialIndexManager' import type { + BatchUpdateSlotsOperation, CreateNodeOperation, + CreateSlotOperation, DeleteNodeOperation, + DeleteSlotOperation, LayoutOperation, MoveNodeOperation, ResizeNodeOperation, - SetNodeZIndexOperation + SetNodeZIndexOperation, + UpdateSlotOperation } from '@/types/layoutOperations' import type { Bounds, @@ -24,7 +28,9 @@ import type { LayoutStore, NodeId, NodeLayout, - Point + Point, + SlotId, + SlotLayout } from '@/types/layoutTypes' // Create logger for layout store @@ -38,6 +44,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 yslots: Y.Map> // Maps slotId -> Y.Map containing SlotLayout data private yoperations: Y.Array // Operation log // Vue reactivity layer @@ -54,6 +61,8 @@ class LayoutStoreImpl implements LayoutStore { // CustomRef cache and trigger functions private nodeRefs = new Map>() private nodeTriggers = new Map void>() + private slotRefs = new Map>() + private slotTriggers = new Map void>() // Spatial index manager private spatialIndex: SpatialIndexManager @@ -61,6 +70,7 @@ class LayoutStoreImpl implements LayoutStore { constructor() { // Initialize Yjs data structures this.ynodes = this.ydoc.getMap('nodes') + this.yslots = this.ydoc.getMap('slots') this.yoperations = this.ydoc.getArray('operations') // Initialize spatial index manager @@ -80,6 +90,20 @@ class LayoutStoreImpl implements LayoutStore { }) }) + // Listen for slot changes + this.yslots.observe((event) => { + this.version++ + + // Trigger all affected slot refs + event.changes.keys.forEach((_change, key) => { + const trigger = this.slotTriggers.get(key) + if (trigger) { + logger.debug(`Yjs change detected for slot ${key}, triggering ref`) + trigger() + } + }) + }) + // Debug: Log layout operations if (localStorage.getItem(DEBUG_CONFIG.LAYOUT_DEBUG_KEY) === 'true') { this.yoperations.observe((event) => { @@ -248,6 +272,140 @@ class LayoutStoreImpl implements LayoutStore { }) } + /** + * Get or create a customRef for a slot layout + */ + getSlotLayoutRef(slotId: SlotId): Ref { + let slotRef = this.slotRefs.get(slotId) + + if (!slotRef) { + logger.debug(`Creating new layout ref for slot ${slotId}`) + + slotRef = customRef((track, trigger) => { + // Store the trigger so we can call it when Yjs changes + this.slotTriggers.set(slotId, trigger) + + return { + get: () => { + track() + const yslot = this.yslots.get(slotId) + const layout = yslot ? this.ySlotToLayout(yslot) : null + logger.debug(`Layout ref GET for slot ${slotId}:`, { + position: layout?.position, + hasYslot: !!yslot, + version: this.version + }) + return layout + }, + set: (newLayout: SlotLayout | null) => { + if (newLayout === null) { + // Delete operation + const existing = this.yslots.get(slotId) + if (existing) { + this.applyOperation({ + type: 'deleteSlot', + slotId, + timestamp: Date.now(), + source: this.currentSource, + actor: this.currentActor, + previousLayout: this.ySlotToLayout(existing) + }) + } + } else { + // Update or create operation + const existing = this.yslots.get(slotId) + if (!existing) { + // Create operation + this.applyOperation({ + type: 'createSlot', + slotId, + layout: newLayout, + timestamp: Date.now(), + source: this.currentSource, + actor: this.currentActor + }) + } else { + const existingLayout = this.ySlotToLayout(existing) + // Update position if changed + if ( + existingLayout.position.x !== newLayout.position.x || + existingLayout.position.y !== newLayout.position.y + ) { + this.applyOperation({ + type: 'updateSlot', + slotId, + position: newLayout.position, + previousPosition: existingLayout.position, + timestamp: Date.now(), + source: this.currentSource, + actor: this.currentActor + }) + } + } + } + logger.debug(`Layout ref SET triggering for slot ${slotId}`) + trigger() + } + } + }) + + this.slotRefs.set(slotId, slotRef) + } + + return slotRef + } + + /** + * Get slots for a specific node (reactive) + */ + getNodeSlots(nodeId: NodeId): ComputedRef { + return computed(() => { + // Touch version for reactivity + void this.version + + const result: SlotLayout[] = [] + for (const [slotId] of this.yslots) { + const yslot = this.yslots.get(slotId) + if (yslot) { + const layout = this.ySlotToLayout(yslot) + if (layout && layout.nodeId === nodeId) { + result.push(layout) + } + } + } + // Sort by type and index + result.sort((a, b) => { + if (a.type !== b.type) { + return a.type === 'input' ? -1 : 1 + } + return a.index - b.index + }) + return result + }) + } + + /** + * Get all slots as a reactive map + */ + getAllSlots(): ComputedRef> { + return computed(() => { + // Touch version for reactivity + void this.version + + const result = new Map() + for (const [slotId] of this.yslots) { + const yslot = this.yslots.get(slotId) + if (yslot) { + const layout = this.ySlotToLayout(yslot) + if (layout) { + result.set(slotId, layout) + } + } + } + return result + }) + } + /** * Get current version for change detection */ @@ -290,13 +448,54 @@ class LayoutStoreImpl implements LayoutStore { return this.spatialIndex.query(bounds) } + /** + * Query slot at point (non-reactive for performance) + */ + querySlotAtPoint(point: Point): SlotId | null { + // First find the node at the point + const nodeId = this.queryNodeAtPoint(point) + if (!nodeId) return null + + // Then check slots for that node + for (const [slotId] of this.yslots) { + const yslot = this.yslots.get(slotId) + if (yslot) { + const slot = this.ySlotToLayout(yslot) + if (slot && slot.nodeId === nodeId) { + const ynode = this.ynodes.get(nodeId) + if (ynode) { + const node = this.yNodeToLayout(ynode) + // Convert slot relative position to absolute + const absoluteX = node.position.x + slot.position.x + const absoluteY = node.position.y + slot.position.y + // Check if point is within slot radius (typically 10-15 pixels) + const slotRadius = 15 + const dx = point.x - absoluteX + const dy = point.y - absoluteY + if (dx * dx + dy * dy <= slotRadius * slotRadius) { + return slotId + } + } + } + } + } + return null + } + /** * Apply a layout operation using Yjs transactions */ applyOperation(operation: LayoutOperation): void { + const entityId = + 'nodeId' in operation + ? operation.nodeId + : 'slotId' in operation + ? (operation as any).slotId + : 'unknown' + logger.debug(`applyOperation called:`, { type: operation.type, - nodeId: operation.nodeId, + entityId, operation }) @@ -345,6 +544,21 @@ class LayoutStoreImpl implements LayoutStore { case 'deleteNode': this.handleDeleteNode(operation as DeleteNodeOperation, change) break + case 'createSlot': + this.handleCreateSlot(operation as CreateSlotOperation, change) + break + case 'updateSlot': + this.handleUpdateSlot(operation as UpdateSlotOperation, change) + break + case 'deleteSlot': + this.handleDeleteSlot(operation as DeleteSlotOperation, change) + break + case 'batchUpdateSlots': + this.handleBatchUpdateSlots( + operation as BatchUpdateSlotsOperation, + change + ) + break } } @@ -548,6 +762,87 @@ class LayoutStoreImpl implements LayoutStore { change.nodeIds.push(operation.nodeId) } + // Slot operation handlers + private handleCreateSlot( + operation: CreateSlotOperation, + change: LayoutChange + ): void { + const yslot = this.layoutToYSlot(operation.layout) + this.yslots.set(operation.slotId, yslot) + + change.type = 'create' + // Track the affected node + change.nodeIds.push(operation.layout.nodeId) + } + + private handleUpdateSlot( + operation: UpdateSlotOperation, + change: LayoutChange + ): void { + const yslot = this.yslots.get(operation.slotId) + if (!yslot) { + logger.warn(`No yslot found for ${operation.slotId}`) + return + } + + logger.debug(`Updating slot ${operation.slotId}`, operation.position) + yslot.set('position', operation.position) + + // Track the affected node + const nodeId = yslot.get('nodeId') as string + if (nodeId) { + change.nodeIds.push(nodeId) + } + } + + private handleDeleteSlot( + operation: DeleteSlotOperation, + change: LayoutChange + ): void { + const yslot = this.yslots.get(operation.slotId) + if (!yslot) return + + // Track the affected node before deletion + const nodeId = yslot.get('nodeId') as string + + this.yslots.delete(operation.slotId) + this.slotRefs.delete(operation.slotId) + this.slotTriggers.delete(operation.slotId) + + change.type = 'delete' + if (nodeId) { + change.nodeIds.push(nodeId) + } + } + + private handleBatchUpdateSlots( + operation: BatchUpdateSlotsOperation, + change: LayoutChange + ): void { + // Delete all existing slots for this node + const slotsToDelete: string[] = [] + for (const [slotId] of this.yslots) { + const yslot = this.yslots.get(slotId) + if (yslot && yslot.get('nodeId') === operation.nodeId) { + slotsToDelete.push(slotId) + } + } + + slotsToDelete.forEach((slotId) => { + this.yslots.delete(slotId) + this.slotRefs.delete(slotId) + this.slotTriggers.delete(slotId) + }) + + // Add new slots + operation.slots.forEach((slotLayout) => { + const yslot = this.layoutToYSlot(slotLayout) + this.yslots.set(slotLayout.id, yslot) + }) + + change.nodeIds.push(operation.nodeId) + } + /** * Update node bounds helper */ @@ -587,6 +882,26 @@ class LayoutStoreImpl implements LayoutStore { } } + private layoutToYSlot(layout: SlotLayout): Y.Map { + const yslot = new Y.Map() + yslot.set('id', layout.id) + yslot.set('nodeId', layout.nodeId) + yslot.set('position', layout.position) + yslot.set('type', layout.type) + yslot.set('index', layout.index) + return yslot + } + + private ySlotToLayout(yslot: Y.Map): SlotLayout { + return { + id: yslot.get('id') as string, + nodeId: yslot.get('nodeId') as string, + position: yslot.get('position') as Point, + type: yslot.get('type') as 'input' | 'output', + index: yslot.get('index') as number + } + } + private notifyChange(change: LayoutChange): void { this.changeListeners.forEach((listener) => { try { diff --git a/src/types/layoutOperations.ts b/src/types/layoutOperations.ts index 23fe18158..2073d2320 100644 --- a/src/types/layoutOperations.ts +++ b/src/types/layoutOperations.ts @@ -8,7 +8,13 @@ * - Conflict resolution (CRDT) * - Debugging (actor, timestamp, source) */ -import type { NodeId, NodeLayout, Point } from './layoutTypes' +import type { + NodeId, + NodeLayout, + Point, + SlotId, + SlotLayout +} from './layoutTypes' /** * Base operation interface that all operations extend @@ -37,6 +43,10 @@ export type OperationType = | 'deleteNode' | 'setNodeVisibility' | 'batchUpdate' + | 'createSlot' + | 'updateSlot' + | 'deleteSlot' + | 'batchUpdateSlots' /** * Move node operation @@ -99,6 +109,56 @@ export interface BatchUpdateOperation extends BaseOperation { previousValues: Partial } +/** + * Base slot operation interface + */ +export interface BaseSlotOperation { + /** 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' + /** Slot this operation affects */ + slotId: SlotId +} + +/** + * Create slot operation + */ +export interface CreateSlotOperation extends BaseSlotOperation { + type: 'createSlot' + layout: SlotLayout +} + +/** + * Update slot position operation + */ +export interface UpdateSlotOperation extends BaseSlotOperation { + type: 'updateSlot' + position: Point + previousPosition: Point +} + +/** + * Delete slot operation + */ +export interface DeleteSlotOperation extends BaseSlotOperation { + type: 'deleteSlot' + previousLayout: SlotLayout +} + +/** + * Batch update slots operation for a node + */ +export interface BatchUpdateSlotsOperation extends BaseOperation { + type: 'batchUpdateSlots' + slots: SlotLayout[] + previousSlots: SlotLayout[] +} + /** * Union of all operation types */ @@ -110,6 +170,10 @@ export type LayoutOperation = | DeleteNodeOperation | SetNodeVisibilityOperation | BatchUpdateOperation + | CreateSlotOperation + | UpdateSlotOperation + | DeleteSlotOperation + | BatchUpdateSlotsOperation /** * Type guards for operations @@ -141,6 +205,22 @@ export const isDeleteNodeOperation = ( op: LayoutOperation ): op is DeleteNodeOperation => op.type === 'deleteNode' +export const isCreateSlotOperation = ( + op: LayoutOperation +): op is CreateSlotOperation => op.type === 'createSlot' + +export const isUpdateSlotOperation = ( + op: LayoutOperation +): op is UpdateSlotOperation => op.type === 'updateSlot' + +export const isDeleteSlotOperation = ( + op: LayoutOperation +): op is DeleteSlotOperation => op.type === 'deleteSlot' + +export const isBatchUpdateSlotsOperation = ( + op: LayoutOperation +): op is BatchUpdateSlotsOperation => op.type === 'batchUpdateSlots' + /** * Operation application interface */ diff --git a/src/types/layoutTypes.ts b/src/types/layoutTypes.ts index 541874bb9..3ed3bbc10 100644 --- a/src/types/layoutTypes.ts +++ b/src/types/layoutTypes.ts @@ -131,15 +131,23 @@ export interface LayoutChange { // Store interfaces export interface LayoutStore { - // CustomRef accessors for shared write access + // Node accessors getNodeLayoutRef(nodeId: NodeId): Ref getNodesInBounds(bounds: Bounds): ComputedRef getAllNodes(): ComputedRef> + + // Slot accessors + getSlotLayoutRef(slotId: SlotId): Ref + getNodeSlots(nodeId: NodeId): ComputedRef + getAllSlots(): ComputedRef> + + // Version tracking getVersion(): ComputedRef // Spatial queries (non-reactive) queryNodeAtPoint(point: Point): NodeId | null queryNodesInBounds(bounds: Bounds): NodeId[] + querySlotAtPoint(point: Point): SlotId | null // Direct mutation API (CRDT-ready) applyOperation(operation: LayoutOperation): void @@ -163,6 +171,7 @@ export interface LayoutStore { export type { LayoutOperation as AnyLayoutOperation, BaseOperation, + BaseSlotOperation, MoveNodeOperation, ResizeNodeOperation, SetNodeZIndexOperation, @@ -170,6 +179,10 @@ export type { DeleteNodeOperation, SetNodeVisibilityOperation, BatchUpdateOperation, + CreateSlotOperation, + UpdateSlotOperation, + DeleteSlotOperation, + BatchUpdateSlotsOperation, OperationType, OperationApplicator, OperationSerializer,