mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-27 18:24:11 +00:00
666 lines
18 KiB
TypeScript
666 lines
18 KiB
TypeScript
/**
|
|
* Layout Store - Single Source of Truth
|
|
*
|
|
* Uses Yjs for efficient local state management and future collaboration.
|
|
* CRDT ensures conflict-free operations for both single and multi-user scenarios.
|
|
*/
|
|
import log from 'loglevel'
|
|
import { type ComputedRef, type Ref, computed, customRef } from 'vue'
|
|
import * as Y from 'yjs'
|
|
|
|
import { ACTOR_CONFIG, DEBUG_CONFIG } from '@/constants/layout'
|
|
import { SpatialIndexManager } from '@/services/spatialIndexManager'
|
|
import type {
|
|
CreateNodeOperation,
|
|
DeleteNodeOperation,
|
|
LayoutOperation,
|
|
MoveNodeOperation,
|
|
ResizeNodeOperation,
|
|
SetNodeZIndexOperation
|
|
} from '@/types/layoutOperations'
|
|
import type {
|
|
Bounds,
|
|
LayoutChange,
|
|
LayoutStore,
|
|
NodeId,
|
|
NodeLayout,
|
|
Point
|
|
} from '@/types/layoutTypes'
|
|
|
|
// Create logger for layout store
|
|
const logger = log.getLogger(DEBUG_CONFIG.STORE_LOGGER_NAME)
|
|
// In dev mode, always show debug logs
|
|
if (import.meta.env.DEV) {
|
|
logger.setLevel('debug')
|
|
}
|
|
|
|
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<LayoutOperation> // Operation log
|
|
|
|
// Vue reactivity layer
|
|
private version = 0
|
|
private currentSource: 'canvas' | 'vue' | 'external' =
|
|
ACTOR_CONFIG.DEFAULT_SOURCE
|
|
private currentActor = `${ACTOR_CONFIG.USER_PREFIX}${Math.random()
|
|
.toString(36)
|
|
.substr(2, ACTOR_CONFIG.ID_LENGTH)}`
|
|
|
|
// Change listeners
|
|
private changeListeners = new Set<(change: LayoutChange) => void>()
|
|
|
|
// CustomRef cache and trigger functions
|
|
private nodeRefs = new Map<NodeId, Ref<NodeLayout | null>>()
|
|
private nodeTriggers = new Map<NodeId, () => void>()
|
|
|
|
// Spatial index manager
|
|
private spatialIndex: SpatialIndexManager
|
|
|
|
constructor() {
|
|
// Initialize Yjs data structures
|
|
this.ynodes = this.ydoc.getMap('nodes')
|
|
this.yoperations = this.ydoc.getArray('operations')
|
|
|
|
// Initialize spatial index manager
|
|
this.spatialIndex = new SpatialIndexManager()
|
|
|
|
// Listen for Yjs changes and trigger Vue reactivity
|
|
this.ynodes.observe((event) => {
|
|
this.version++
|
|
|
|
// Trigger all affected node refs
|
|
event.changes.keys.forEach((_change, key) => {
|
|
const trigger = this.nodeTriggers.get(key)
|
|
if (trigger) {
|
|
logger.debug(`Yjs change detected for node ${key}, triggering ref`)
|
|
trigger()
|
|
}
|
|
})
|
|
})
|
|
|
|
// Debug: Log layout operations
|
|
if (localStorage.getItem(DEBUG_CONFIG.LAYOUT_DEBUG_KEY) === 'true') {
|
|
this.yoperations.observe((event) => {
|
|
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 LayoutOperation)
|
|
}
|
|
})
|
|
console.log('Layout Operation:', operations)
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get or create a customRef for a node layout
|
|
*/
|
|
getNodeLayoutRef(nodeId: NodeId): Ref<NodeLayout | null> {
|
|
let nodeRef = this.nodeRefs.get(nodeId)
|
|
|
|
if (!nodeRef) {
|
|
logger.debug(`Creating new layout ref for node ${nodeId}`)
|
|
|
|
nodeRef = customRef<NodeLayout | null>((track, trigger) => {
|
|
// Store the trigger so we can call it when Yjs changes
|
|
this.nodeTriggers.set(nodeId, trigger)
|
|
|
|
return {
|
|
get: () => {
|
|
track()
|
|
const ynode = this.ynodes.get(nodeId)
|
|
const layout = ynode ? this.yNodeToLayout(ynode) : null
|
|
logger.debug(`Layout ref GET for node ${nodeId}:`, {
|
|
position: layout?.position,
|
|
hasYnode: !!ynode,
|
|
version: this.version
|
|
})
|
|
return layout
|
|
},
|
|
set: (newLayout: NodeLayout | null) => {
|
|
if (newLayout === null) {
|
|
// Delete operation
|
|
const existing = this.ynodes.get(nodeId)
|
|
if (existing) {
|
|
this.applyOperation({
|
|
type: 'deleteNode',
|
|
nodeId,
|
|
timestamp: Date.now(),
|
|
source: this.currentSource,
|
|
actor: this.currentActor,
|
|
previousLayout: this.yNodeToLayout(existing)
|
|
})
|
|
}
|
|
} else {
|
|
// Update operation - detect what changed
|
|
const existing = this.ynodes.get(nodeId)
|
|
if (!existing) {
|
|
// Create operation
|
|
this.applyOperation({
|
|
type: 'createNode',
|
|
nodeId,
|
|
layout: newLayout,
|
|
timestamp: Date.now(),
|
|
source: this.currentSource,
|
|
actor: this.currentActor
|
|
})
|
|
} else {
|
|
const existingLayout = this.yNodeToLayout(existing)
|
|
|
|
// Check what properties changed
|
|
if (
|
|
existingLayout.position.x !== newLayout.position.x ||
|
|
existingLayout.position.y !== newLayout.position.y
|
|
) {
|
|
this.applyOperation({
|
|
type: 'moveNode',
|
|
nodeId,
|
|
position: newLayout.position,
|
|
previousPosition: existingLayout.position,
|
|
timestamp: Date.now(),
|
|
source: this.currentSource,
|
|
actor: this.currentActor
|
|
})
|
|
}
|
|
if (
|
|
existingLayout.size.width !== newLayout.size.width ||
|
|
existingLayout.size.height !== newLayout.size.height
|
|
) {
|
|
this.applyOperation({
|
|
type: 'resizeNode',
|
|
nodeId,
|
|
size: newLayout.size,
|
|
previousSize: existingLayout.size,
|
|
timestamp: Date.now(),
|
|
source: this.currentSource,
|
|
actor: this.currentActor
|
|
})
|
|
}
|
|
if (existingLayout.zIndex !== newLayout.zIndex) {
|
|
this.applyOperation({
|
|
type: 'setNodeZIndex',
|
|
nodeId,
|
|
zIndex: newLayout.zIndex,
|
|
previousZIndex: existingLayout.zIndex,
|
|
timestamp: Date.now(),
|
|
source: this.currentSource,
|
|
actor: this.currentActor
|
|
})
|
|
}
|
|
}
|
|
}
|
|
logger.debug(`Layout ref SET triggering for node ${nodeId}`)
|
|
trigger()
|
|
}
|
|
}
|
|
})
|
|
|
|
this.nodeRefs.set(nodeId, nodeRef)
|
|
}
|
|
|
|
return nodeRef
|
|
}
|
|
|
|
/**
|
|
* Get nodes within bounds (reactive)
|
|
*/
|
|
getNodesInBounds(bounds: Bounds): ComputedRef<NodeId[]> {
|
|
return computed(() => {
|
|
// Touch version for reactivity
|
|
void this.version
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Get all nodes as a reactive map
|
|
*/
|
|
getAllNodes(): ComputedRef<ReadonlyMap<NodeId, NodeLayout>> {
|
|
return computed(() => {
|
|
// Touch version for reactivity
|
|
void this.version
|
|
|
|
const result = new Map<NodeId, NodeLayout>()
|
|
for (const [nodeId] of this.ynodes) {
|
|
const ynode = this.ynodes.get(nodeId)
|
|
if (ynode) {
|
|
const layout = this.yNodeToLayout(ynode)
|
|
if (layout) {
|
|
result.set(nodeId, layout)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Get current version for change detection
|
|
*/
|
|
getVersion(): ComputedRef<number> {
|
|
return computed(() => this.version)
|
|
}
|
|
|
|
/**
|
|
* Query node at point (non-reactive for performance)
|
|
*/
|
|
queryNodeAtPoint(point: Point): NodeId | null {
|
|
const nodes: Array<[NodeId, NodeLayout]> = []
|
|
|
|
for (const [nodeId] of this.ynodes) {
|
|
const ynode = this.ynodes.get(nodeId)
|
|
if (ynode) {
|
|
const layout = this.yNodeToLayout(ynode)
|
|
if (layout) {
|
|
nodes.push([nodeId, layout])
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by zIndex (top to bottom)
|
|
nodes.sort(([, a], [, b]) => b.zIndex - a.zIndex)
|
|
|
|
for (const [nodeId, layout] of nodes) {
|
|
if (this.pointInBounds(point, layout.bounds)) {
|
|
return nodeId
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Query nodes in bounds (non-reactive for performance)
|
|
*/
|
|
queryNodesInBounds(bounds: Bounds): NodeId[] {
|
|
return this.spatialIndex.query(bounds)
|
|
}
|
|
|
|
/**
|
|
* Apply a layout operation using Yjs transactions
|
|
*/
|
|
applyOperation(operation: LayoutOperation): void {
|
|
logger.debug(`applyOperation called:`, {
|
|
type: operation.type,
|
|
nodeId: operation.nodeId,
|
|
operation
|
|
})
|
|
|
|
// Create change object outside transaction so we can use it after
|
|
const change: LayoutChange = {
|
|
type: 'update',
|
|
nodeIds: [],
|
|
timestamp: operation.timestamp,
|
|
source: operation.source,
|
|
operation
|
|
}
|
|
|
|
// Use Yjs transaction for atomic updates
|
|
this.ydoc.transact(() => {
|
|
// Add operation to log
|
|
this.yoperations.push([operation])
|
|
|
|
// Apply the operation
|
|
this.applyOperationInTransaction(operation, change)
|
|
}, this.currentActor)
|
|
|
|
// Post-transaction updates
|
|
this.finalizeOperation(change)
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
this.version++
|
|
|
|
// Manually trigger affected node refs after transaction
|
|
// This is needed because Yjs observers don't fire for property changes
|
|
change.nodeIds.forEach((nodeId) => {
|
|
const trigger = this.nodeTriggers.get(nodeId)
|
|
if (trigger) {
|
|
logger.debug(
|
|
`Manually triggering ref for node ${nodeId} after operation`
|
|
)
|
|
trigger()
|
|
}
|
|
})
|
|
|
|
// Notify listeners (after transaction completes)
|
|
setTimeout(() => this.notifyChange(change), 0)
|
|
}
|
|
|
|
/**
|
|
* Subscribe to layout changes
|
|
*/
|
|
onChange(callback: (change: LayoutChange) => void): () => void {
|
|
this.changeListeners.add(callback)
|
|
return () => this.changeListeners.delete(callback)
|
|
}
|
|
|
|
/**
|
|
* Set the current operation source
|
|
*/
|
|
setSource(source: 'canvas' | 'vue' | 'external'): void {
|
|
this.currentSource = source
|
|
}
|
|
|
|
/**
|
|
* Set the current actor (for CRDT)
|
|
*/
|
|
setActor(actor: string): void {
|
|
this.currentActor = actor
|
|
}
|
|
|
|
/**
|
|
* Get the current operation source
|
|
*/
|
|
getCurrentSource(): 'canvas' | 'vue' | 'external' {
|
|
return this.currentSource
|
|
}
|
|
|
|
/**
|
|
* Get the current actor
|
|
*/
|
|
getCurrentActor(): string {
|
|
return this.currentActor
|
|
}
|
|
|
|
/**
|
|
* Initialize store with existing nodes
|
|
*/
|
|
initializeFromLiteGraph(
|
|
nodes: Array<{ id: string; pos: [number, number]; size: [number, number] }>
|
|
): void {
|
|
logger.debug('Initializing layout store from LiteGraph', {
|
|
nodeCount: nodes.length,
|
|
nodes: nodes.map((n) => ({ id: n.id, pos: n.pos }))
|
|
})
|
|
|
|
this.ydoc.transact(() => {
|
|
this.ynodes.clear()
|
|
this.nodeRefs.clear()
|
|
this.nodeTriggers.clear()
|
|
this.spatialIndex.clear()
|
|
|
|
nodes.forEach((node, index) => {
|
|
const layout: NodeLayout = {
|
|
id: node.id.toString(),
|
|
position: { x: node.pos[0], y: node.pos[1] },
|
|
size: { width: node.size[0], height: node.size[1] },
|
|
zIndex: index,
|
|
visible: true,
|
|
bounds: {
|
|
x: node.pos[0],
|
|
y: node.pos[1],
|
|
width: node.size[0],
|
|
height: node.size[1]
|
|
}
|
|
}
|
|
|
|
this.ynodes.set(layout.id, this.layoutToYNode(layout))
|
|
|
|
// Add to spatial index
|
|
this.spatialIndex.insert(layout.id, layout.bounds)
|
|
|
|
logger.debug(
|
|
`Initialized node ${layout.id} at position:`,
|
|
layout.position
|
|
)
|
|
})
|
|
}, 'initialization')
|
|
|
|
logger.debug('Layout store initialization complete', {
|
|
totalNodes: this.ynodes.size
|
|
})
|
|
}
|
|
|
|
// Operation handlers
|
|
private handleMoveNode(
|
|
operation: MoveNodeOperation,
|
|
change: LayoutChange
|
|
): void {
|
|
const ynode = this.ynodes.get(operation.nodeId)
|
|
if (!ynode) {
|
|
logger.warn(`No ynode found for ${operation.nodeId}`)
|
|
return
|
|
}
|
|
|
|
logger.debug(`Moving node ${operation.nodeId}`, operation.position)
|
|
|
|
const size = ynode.get('size') as { width: number; height: number }
|
|
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,
|
|
height: size.height
|
|
})
|
|
|
|
change.nodeIds.push(operation.nodeId)
|
|
}
|
|
|
|
private handleResizeNode(
|
|
operation: ResizeNodeOperation,
|
|
change: LayoutChange
|
|
): void {
|
|
const ynode = this.ynodes.get(operation.nodeId)
|
|
if (!ynode) return
|
|
|
|
const position = ynode.get('position') as Point
|
|
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,
|
|
height: operation.size.height
|
|
})
|
|
|
|
change.nodeIds.push(operation.nodeId)
|
|
}
|
|
|
|
private handleSetNodeZIndex(
|
|
operation: SetNodeZIndexOperation,
|
|
change: LayoutChange
|
|
): void {
|
|
const ynode = this.ynodes.get(operation.nodeId)
|
|
if (!ynode) return
|
|
|
|
ynode.set('zIndex', operation.zIndex)
|
|
change.nodeIds.push(operation.nodeId)
|
|
}
|
|
|
|
private handleCreateNode(
|
|
operation: CreateNodeOperation,
|
|
change: LayoutChange
|
|
): void {
|
|
const ynode = this.layoutToYNode(operation.layout)
|
|
this.ynodes.set(operation.nodeId, ynode)
|
|
|
|
// Add to spatial index
|
|
this.spatialIndex.insert(operation.nodeId, operation.layout.bounds)
|
|
|
|
change.type = 'create'
|
|
change.nodeIds.push(operation.nodeId)
|
|
}
|
|
|
|
private handleDeleteNode(
|
|
operation: DeleteNodeOperation,
|
|
change: LayoutChange
|
|
): void {
|
|
if (!this.ynodes.has(operation.nodeId)) return
|
|
|
|
this.ynodes.delete(operation.nodeId)
|
|
this.nodeRefs.delete(operation.nodeId)
|
|
this.nodeTriggers.delete(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
|
|
private layoutToYNode(layout: NodeLayout): Y.Map<unknown> {
|
|
const ynode = new Y.Map<unknown>()
|
|
ynode.set('id', layout.id)
|
|
ynode.set('position', layout.position)
|
|
ynode.set('size', layout.size)
|
|
ynode.set('zIndex', layout.zIndex)
|
|
ynode.set('visible', layout.visible)
|
|
ynode.set('bounds', layout.bounds)
|
|
return ynode
|
|
}
|
|
|
|
private yNodeToLayout(ynode: Y.Map<unknown>): NodeLayout {
|
|
return {
|
|
id: ynode.get('id') as string,
|
|
position: ynode.get('position') as Point,
|
|
size: ynode.get('size') as { width: number; height: number },
|
|
zIndex: ynode.get('zIndex') as number,
|
|
visible: ynode.get('visible') as boolean,
|
|
bounds: ynode.get('bounds') as Bounds
|
|
}
|
|
}
|
|
|
|
private notifyChange(change: LayoutChange): void {
|
|
this.changeListeners.forEach((listener) => {
|
|
try {
|
|
listener(change)
|
|
} catch (error) {
|
|
console.error('Error in layout change listener:', error)
|
|
}
|
|
})
|
|
}
|
|
|
|
private pointInBounds(point: Point, bounds: Bounds): boolean {
|
|
return (
|
|
point.x >= bounds.x &&
|
|
point.x <= bounds.x + bounds.width &&
|
|
point.y >= bounds.y &&
|
|
point.y <= bounds.y + bounds.height
|
|
)
|
|
}
|
|
|
|
private boundsIntersect(a: Bounds, b: Bounds): boolean {
|
|
return !(
|
|
a.x + a.width < b.x ||
|
|
b.x + b.width < a.x ||
|
|
a.y + a.height < b.y ||
|
|
b.y + b.height < a.y
|
|
)
|
|
}
|
|
|
|
// CRDT-specific methods
|
|
getOperationsSince(timestamp: number): LayoutOperation[] {
|
|
const operations: LayoutOperation[] = []
|
|
this.yoperations.forEach((op) => {
|
|
if (op && op.timestamp > timestamp) {
|
|
operations.push(op)
|
|
}
|
|
})
|
|
return operations
|
|
}
|
|
|
|
getOperationsByActor(actor: string): LayoutOperation[] {
|
|
const operations: LayoutOperation[] = []
|
|
this.yoperations.forEach((op) => {
|
|
if (op && op.actor === actor) {
|
|
operations.push(op)
|
|
}
|
|
})
|
|
return operations
|
|
}
|
|
|
|
/**
|
|
* Get the Yjs document for network sync (future feature)
|
|
*/
|
|
getYDoc(): Y.Doc {
|
|
return this.ydoc
|
|
}
|
|
|
|
/**
|
|
* Apply updates from remote peers (future feature)
|
|
*/
|
|
applyUpdate(update: Uint8Array): void {
|
|
Y.applyUpdate(this.ydoc, update)
|
|
}
|
|
|
|
/**
|
|
* Get state as update for sending to peers (future feature)
|
|
*/
|
|
getStateAsUpdate(): Uint8Array {
|
|
return Y.encodeStateAsUpdate(this.ydoc)
|
|
}
|
|
}
|
|
|
|
// Create singleton instance
|
|
export const layoutStore = new LayoutStoreImpl()
|
|
|
|
// Export types for convenience
|
|
export type { LayoutStore } from '@/types/layoutTypes'
|