mirror of
https://github.com/langgenius/dify.git
synced 2026-04-28 20:17:29 +08:00
try to fix start node collaboration
This commit is contained in:
parent
946f0b00e4
commit
6af9aeb345
@ -0,0 +1,69 @@
|
|||||||
|
import { LoroDoc } from 'loro-crdt'
|
||||||
|
import { CollaborationManager } from '../collaboration-manager'
|
||||||
|
import type { Node } from '@/app/components/workflow/types'
|
||||||
|
import { BlockEnum } from '@/app/components/workflow/types'
|
||||||
|
|
||||||
|
const NODE_ID = 'node-1'
|
||||||
|
|
||||||
|
const createNode = (variables: string[]): Node => ({
|
||||||
|
id: NODE_ID,
|
||||||
|
type: 'custom',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
type: BlockEnum.Start,
|
||||||
|
title: 'Start',
|
||||||
|
desc: '',
|
||||||
|
variables: variables.map(name => ({
|
||||||
|
variable: name,
|
||||||
|
label: name,
|
||||||
|
type: 'text-input',
|
||||||
|
required: true,
|
||||||
|
default: '',
|
||||||
|
max_length: 48,
|
||||||
|
placeholder: '',
|
||||||
|
options: [],
|
||||||
|
hint: '',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const getManager = (doc: LoroDoc) => {
|
||||||
|
const manager = new CollaborationManager()
|
||||||
|
;(manager as any).doc = doc
|
||||||
|
;(manager as any).nodesMap = doc.getMap('nodes')
|
||||||
|
;(manager as any).edgesMap = doc.getMap('edges')
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportNodes = (manager: CollaborationManager) => manager.getNodes()
|
||||||
|
|
||||||
|
describe('Loro merge behavior smoke test', () => {
|
||||||
|
it('inspects concurrent edits after merge', () => {
|
||||||
|
const docA = new LoroDoc()
|
||||||
|
const managerA = getManager(docA)
|
||||||
|
managerA.syncNodes([], [createNode(['a'])])
|
||||||
|
|
||||||
|
const snapshot = docA.export({ mode: 'snapshot' })
|
||||||
|
|
||||||
|
const docB = LoroDoc.fromSnapshot(snapshot)
|
||||||
|
const managerB = getManager(docB)
|
||||||
|
|
||||||
|
managerA.syncNodes([createNode(['a'])], [createNode(['a', 'b'])])
|
||||||
|
managerB.syncNodes([createNode(['a'])], [createNode(['a', 'c'])])
|
||||||
|
|
||||||
|
const updateForA = docB.export({ mode: 'update', from: docA.version() })
|
||||||
|
docA.import(updateForA)
|
||||||
|
|
||||||
|
const updateForB = docA.export({ mode: 'update', from: docB.version() })
|
||||||
|
docB.import(updateForB)
|
||||||
|
|
||||||
|
const finalA = exportNodes(managerA)
|
||||||
|
const finalB = exportNodes(managerB)
|
||||||
|
|
||||||
|
console.log('Final nodes on docA:', JSON.stringify(finalA, null, 2))
|
||||||
|
|
||||||
|
console.log('Final nodes on docB:', JSON.stringify(finalB, null, 2))
|
||||||
|
expect(finalA.length).toBe(1)
|
||||||
|
expect(finalB.length).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,180 @@
|
|||||||
|
import { LoroDoc } from 'loro-crdt'
|
||||||
|
import { CollaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||||
|
import { BlockEnum } from '@/app/components/workflow/types'
|
||||||
|
import type { Node } from '@/app/components/workflow/types'
|
||||||
|
|
||||||
|
const NODE_ID = '1760342909316'
|
||||||
|
|
||||||
|
type WorkflowVariable = {
|
||||||
|
default: string
|
||||||
|
hint: string
|
||||||
|
label: string
|
||||||
|
max_length: number
|
||||||
|
options: string[]
|
||||||
|
placeholder: string
|
||||||
|
required: boolean
|
||||||
|
type: string
|
||||||
|
variable: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const createVariable = (name: string, overrides: Partial<WorkflowVariable> = {}): WorkflowVariable => ({
|
||||||
|
default: '',
|
||||||
|
hint: '',
|
||||||
|
label: name,
|
||||||
|
max_length: 48,
|
||||||
|
options: [],
|
||||||
|
placeholder: '',
|
||||||
|
required: true,
|
||||||
|
type: 'text-input',
|
||||||
|
variable: name,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const deepClone = <T>(value: T): T => JSON.parse(JSON.stringify(value))
|
||||||
|
|
||||||
|
const createNodeSnapshot = (variableNames: string[]): Node<{ variables: WorkflowVariable[] }> => ({
|
||||||
|
id: NODE_ID,
|
||||||
|
type: 'custom',
|
||||||
|
position: { x: 0, y: 24 },
|
||||||
|
positionAbsolute: { x: 0, y: 24 },
|
||||||
|
height: 88,
|
||||||
|
width: 242,
|
||||||
|
selected: true,
|
||||||
|
selectable: true,
|
||||||
|
draggable: true,
|
||||||
|
sourcePosition: 'right',
|
||||||
|
targetPosition: 'left',
|
||||||
|
data: {
|
||||||
|
selected: true,
|
||||||
|
title: '开始',
|
||||||
|
desc: '',
|
||||||
|
type: BlockEnum.Start,
|
||||||
|
variables: variableNames.map(createVariable),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const getVariables = (node: Node): string[] => {
|
||||||
|
const variables = (node.data as any)?.variables ?? []
|
||||||
|
return variables.map((item: WorkflowVariable) => item.variable)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVariableObject = (node: Node, name: string): WorkflowVariable | undefined => {
|
||||||
|
const variables = (node.data as any)?.variables ?? []
|
||||||
|
return variables.find((item: WorkflowVariable) => item.variable === name)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CollaborationManager syncNodes', () => {
|
||||||
|
let manager: CollaborationManager
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = new CollaborationManager()
|
||||||
|
// Bypass private guards for targeted unit testing
|
||||||
|
const doc = new LoroDoc()
|
||||||
|
;(manager as any).doc = doc
|
||||||
|
;(manager as any).nodesMap = doc.getMap('nodes')
|
||||||
|
;(manager as any).edgesMap = doc.getMap('edges')
|
||||||
|
|
||||||
|
const initialNode = createNodeSnapshot(['a'])
|
||||||
|
;(manager as any).syncNodes([], [deepClone(initialNode)])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates collaborators map when a single client adds a variable', () => {
|
||||||
|
const base = [createNodeSnapshot(['a'])]
|
||||||
|
const next = [createNodeSnapshot(['a', 'b'])]
|
||||||
|
|
||||||
|
;(manager as any).syncNodes(base, next)
|
||||||
|
|
||||||
|
const stored = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
|
||||||
|
expect(stored).toBeDefined()
|
||||||
|
expect(getVariables(stored!)).toEqual(['a', 'b'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies the latest parallel additions derived from the same base snapshot', () => {
|
||||||
|
const base = [createNodeSnapshot(['a'])]
|
||||||
|
const userA = [createNodeSnapshot(['a', 'b'])]
|
||||||
|
const userB = [createNodeSnapshot(['a', 'c'])]
|
||||||
|
|
||||||
|
;(manager as any).syncNodes(base, userA)
|
||||||
|
|
||||||
|
const afterUserA = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
|
||||||
|
expect(getVariables(afterUserA!)).toEqual(['a', 'b'])
|
||||||
|
|
||||||
|
;(manager as any).syncNodes(base, userB)
|
||||||
|
|
||||||
|
const finalNode = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
|
||||||
|
const finalVariables = getVariables(finalNode!)
|
||||||
|
|
||||||
|
expect(finalVariables).toEqual(['a', 'c'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers the incoming mutation when the same variable is edited concurrently', () => {
|
||||||
|
const base = [createNodeSnapshot(['a'])]
|
||||||
|
const userA = [
|
||||||
|
{
|
||||||
|
...createNodeSnapshot(['a']),
|
||||||
|
data: {
|
||||||
|
...createNodeSnapshot(['a']).data,
|
||||||
|
variables: [
|
||||||
|
createVariable('a', { label: 'A from userA', hint: 'hintA' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const userB = [
|
||||||
|
{
|
||||||
|
...createNodeSnapshot(['a']),
|
||||||
|
data: {
|
||||||
|
...createNodeSnapshot(['a']).data,
|
||||||
|
variables: [
|
||||||
|
createVariable('a', { label: 'A from userB', hint: 'hintB' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
;(manager as any).syncNodes(base, userA)
|
||||||
|
;(manager as any).syncNodes(base, userB)
|
||||||
|
|
||||||
|
const finalNode = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
|
||||||
|
const finalVariable = getVariableObject(finalNode!, 'a')
|
||||||
|
|
||||||
|
expect(finalVariable?.label).toBe('A from userB')
|
||||||
|
expect(finalVariable?.hint).toBe('hintB')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reflects the last writer when concurrent removal and edits happen', () => {
|
||||||
|
const base = [createNodeSnapshot(['a', 'b'])]
|
||||||
|
;(manager as any).syncNodes([], [deepClone(base[0])])
|
||||||
|
const userA = [
|
||||||
|
{
|
||||||
|
...createNodeSnapshot(['a']),
|
||||||
|
data: {
|
||||||
|
...createNodeSnapshot(['a']).data,
|
||||||
|
variables: [
|
||||||
|
createVariable('a', { label: 'A after deletion' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const userB = [
|
||||||
|
{
|
||||||
|
...createNodeSnapshot(['a', 'b']),
|
||||||
|
data: {
|
||||||
|
...createNodeSnapshot(['a']).data,
|
||||||
|
variables: [
|
||||||
|
createVariable('a'),
|
||||||
|
createVariable('b', { label: 'B edited but should vanish' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
;(manager as any).syncNodes(base, userA)
|
||||||
|
;(manager as any).syncNodes(base, userB)
|
||||||
|
|
||||||
|
const finalNode = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
|
||||||
|
const finalVariables = getVariables(finalNode!)
|
||||||
|
expect(finalVariables).toEqual(['a', 'b'])
|
||||||
|
expect(getVariableObject(finalNode!, 'b')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { LoroDoc, UndoManager } from 'loro-crdt'
|
import { LoroDoc, LoroList, LoroMap, UndoManager } from 'loro-crdt'
|
||||||
import { cloneDeep, isEqual } from 'lodash-es'
|
import { cloneDeep, isEqual } from 'lodash-es'
|
||||||
import { webSocketClient } from './websocket-manager'
|
import { webSocketClient } from './websocket-manager'
|
||||||
import { CRDTProvider } from './crdt-provider'
|
import { CRDTProvider } from './crdt-provider'
|
||||||
@ -37,6 +37,152 @@ export class CollaborationManager {
|
|||||||
private isUndoRedoInProgress = false
|
private isUndoRedoInProgress = false
|
||||||
private pendingInitialSync = false
|
private pendingInitialSync = false
|
||||||
|
|
||||||
|
private getNodeContainer(nodeId: string): LoroMap<any> {
|
||||||
|
if (!this.nodesMap)
|
||||||
|
throw new Error('Nodes map not initialized')
|
||||||
|
|
||||||
|
let container = this.nodesMap.get(nodeId) as any
|
||||||
|
|
||||||
|
if (!container || typeof container.kind !== 'function' || container.kind() !== 'Map') {
|
||||||
|
const previousValue = container
|
||||||
|
const newContainer = this.nodesMap.setContainer(nodeId, new LoroMap())
|
||||||
|
container = typeof newContainer.getAttached === 'function' ? newContainer.getAttached() ?? newContainer : newContainer
|
||||||
|
if (previousValue && typeof previousValue === 'object')
|
||||||
|
this.populateNodeContainer(container, previousValue as Node)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
container = typeof container.getAttached === 'function' ? container.getAttached() ?? container : container
|
||||||
|
}
|
||||||
|
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureDataContainer(nodeContainer: LoroMap<any>): LoroMap<any> {
|
||||||
|
let dataContainer = nodeContainer.get('data') as any
|
||||||
|
|
||||||
|
if (!dataContainer || typeof dataContainer.kind !== 'function' || dataContainer.kind() !== 'Map')
|
||||||
|
dataContainer = nodeContainer.setContainer('data', new LoroMap())
|
||||||
|
|
||||||
|
return typeof dataContainer.getAttached === 'function' ? dataContainer.getAttached() ?? dataContainer : dataContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureVariableList(nodeContainer: LoroMap<any>): LoroList<any> {
|
||||||
|
console.log('variable list, for debug online')
|
||||||
|
const dataContainer = this.ensureDataContainer(nodeContainer)
|
||||||
|
let list = dataContainer.get('variables') as any
|
||||||
|
|
||||||
|
if (!list || typeof list.kind !== 'function' || list.kind() !== 'List')
|
||||||
|
list = dataContainer.setContainer('variables', new LoroList())
|
||||||
|
|
||||||
|
return typeof list.getAttached === 'function' ? list.getAttached() ?? list : list
|
||||||
|
}
|
||||||
|
|
||||||
|
private exportNode(nodeId: string): Node {
|
||||||
|
const container = this.getNodeContainer(nodeId)
|
||||||
|
const json = container.toJSON() as any
|
||||||
|
return {
|
||||||
|
...json,
|
||||||
|
data: json.data || {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private populateNodeContainer(container: LoroMap<any>, node: Node): void {
|
||||||
|
container.set('id', node.id)
|
||||||
|
container.set('type', node.type)
|
||||||
|
container.set('position', cloneDeep(node.position))
|
||||||
|
container.set('sourcePosition', node.sourcePosition)
|
||||||
|
container.set('targetPosition', node.targetPosition)
|
||||||
|
|
||||||
|
if (node.width === undefined) container.delete('width')
|
||||||
|
else container.set('width', node.width)
|
||||||
|
|
||||||
|
if (node.height === undefined) container.delete('height')
|
||||||
|
else container.set('height', node.height)
|
||||||
|
|
||||||
|
if (node.selected === undefined) container.delete('selected')
|
||||||
|
else container.set('selected', node.selected)
|
||||||
|
|
||||||
|
const optionalProps: Array<keyof Node> = [
|
||||||
|
'parentId',
|
||||||
|
'positionAbsolute',
|
||||||
|
'extent',
|
||||||
|
'zIndex',
|
||||||
|
'draggable',
|
||||||
|
'selectable',
|
||||||
|
'dragHandle',
|
||||||
|
'dragging',
|
||||||
|
'connectable',
|
||||||
|
'expandParent',
|
||||||
|
'focusable',
|
||||||
|
'hidden',
|
||||||
|
'style',
|
||||||
|
'className',
|
||||||
|
'ariaLabel',
|
||||||
|
'resizing',
|
||||||
|
'deletable',
|
||||||
|
]
|
||||||
|
|
||||||
|
optionalProps.forEach((prop) => {
|
||||||
|
const value = node[prop]
|
||||||
|
if (value === undefined)
|
||||||
|
container.delete(prop as string)
|
||||||
|
else
|
||||||
|
container.set(prop as string, cloneDeep(value as any))
|
||||||
|
})
|
||||||
|
|
||||||
|
const dataContainer = this.ensureDataContainer(container)
|
||||||
|
const handledKeys = new Set<string>()
|
||||||
|
|
||||||
|
Object.entries(node.data || {}).forEach(([key, value]) => {
|
||||||
|
if (!this.shouldSyncDataKey(key)) return
|
||||||
|
handledKeys.add(key)
|
||||||
|
|
||||||
|
if (key === 'variables')
|
||||||
|
this.syncVariables(container, Array.isArray(value) ? value : [])
|
||||||
|
else
|
||||||
|
dataContainer.set(key, cloneDeep(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const existingData = dataContainer.toJSON() || {}
|
||||||
|
Object.keys(existingData).forEach((key) => {
|
||||||
|
if (!this.shouldSyncDataKey(key)) return
|
||||||
|
if (handledKeys.has(key)) return
|
||||||
|
|
||||||
|
if (key === 'variables')
|
||||||
|
dataContainer.delete('variables')
|
||||||
|
|
||||||
|
else
|
||||||
|
dataContainer.delete(key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldSyncDataKey(key: string): boolean {
|
||||||
|
const syncDataAllowList = new Set(['_children', '_connectedSourceHandleIds', '_connectedTargetHandleIds', '_targetBranches'])
|
||||||
|
return (syncDataAllowList.has(key) || !key.startsWith('_')) && key !== 'selected'
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncVariables(nodeContainer: LoroMap<any>, desired: any[]): void {
|
||||||
|
const list = this.ensureVariableList(nodeContainer)
|
||||||
|
const current = list.toJSON() as any[]
|
||||||
|
const target = Array.isArray(desired) ? desired : []
|
||||||
|
const minLength = Math.min(current.length, target.length)
|
||||||
|
|
||||||
|
for (let i = 0; i < minLength; i += 1) {
|
||||||
|
if (!isEqual(current[i], target[i])) {
|
||||||
|
list.delete(i, 1)
|
||||||
|
list.insert(i, cloneDeep(target[i]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length > target.length) {
|
||||||
|
list.delete(target.length, current.length - target.length)
|
||||||
|
}
|
||||||
|
else if (target.length > current.length) {
|
||||||
|
for (let i = current.length; i < target.length; i += 1)
|
||||||
|
list.insert(i, cloneDeep(target[i]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getNodePanelPresenceSnapshot(): NodePanelPresenceMap {
|
private getNodePanelPresenceSnapshot(): NodePanelPresenceMap {
|
||||||
const snapshot: NodePanelPresenceMap = {}
|
const snapshot: NodePanelPresenceMap = {}
|
||||||
Object.entries(this.nodePanelPresence).forEach(([nodeId, viewers]) => {
|
Object.entries(this.nodePanelPresence).forEach(([nodeId, viewers]) => {
|
||||||
@ -273,7 +419,8 @@ export class CollaborationManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getNodes(): Node[] {
|
getNodes(): Node[] {
|
||||||
return this.nodesMap ? Array.from(this.nodesMap.values()) : []
|
if (!this.nodesMap) return []
|
||||||
|
return Array.from(this.nodesMap.keys()).map(id => this.exportNode(id as string))
|
||||||
}
|
}
|
||||||
|
|
||||||
getEdges(): Edge[] {
|
getEdges(): Edge[] {
|
||||||
@ -547,154 +694,16 @@ export class CollaborationManager {
|
|||||||
private syncNodes(oldNodes: Node[], newNodes: Node[]): void {
|
private syncNodes(oldNodes: Node[], newNodes: Node[]): void {
|
||||||
if (!this.nodesMap || !this.doc) return
|
if (!this.nodesMap || !this.doc) return
|
||||||
|
|
||||||
const oldNodesMap = new Map(oldNodes.map(node => [node.id, node]))
|
const newIdSet = new Set(newNodes.map(node => node.id))
|
||||||
const newNodesMap = new Map(newNodes.map(node => [node.id, node]))
|
|
||||||
const syncDataAllowList = new Set(['_children', '_connectedSourceHandleIds', '_connectedTargetHandleIds', '_targetBranches'])
|
|
||||||
const shouldSyncDataKey = (key: string) => (syncDataAllowList.has(key) || !key.startsWith('_')) && key !== 'selected'
|
|
||||||
|
|
||||||
// Delete removed nodes
|
|
||||||
oldNodes.forEach((oldNode) => {
|
oldNodes.forEach((oldNode) => {
|
||||||
if (!newNodesMap.has(oldNode.id))
|
if (!newIdSet.has(oldNode.id))
|
||||||
this.nodesMap.delete(oldNode.id)
|
this.nodesMap.delete(oldNode.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add or update nodes with fine-grained sync for data properties
|
|
||||||
const copyOptionalNodeProps = (source: Node, target: any) => {
|
|
||||||
const optionalProps: Array<keyof Node | keyof any> = [
|
|
||||||
'parentId',
|
|
||||||
'positionAbsolute',
|
|
||||||
'extent',
|
|
||||||
'zIndex',
|
|
||||||
'draggable',
|
|
||||||
'selectable',
|
|
||||||
'dragHandle',
|
|
||||||
'dragging',
|
|
||||||
'connectable',
|
|
||||||
'expandParent',
|
|
||||||
'focusable',
|
|
||||||
'hidden',
|
|
||||||
'style',
|
|
||||||
'className',
|
|
||||||
'ariaLabel',
|
|
||||||
'markerStart',
|
|
||||||
'markerEnd',
|
|
||||||
'resizing',
|
|
||||||
'deletable',
|
|
||||||
]
|
|
||||||
|
|
||||||
optionalProps.forEach((prop) => {
|
|
||||||
const value = (source as any)[prop]
|
|
||||||
if (value === undefined) {
|
|
||||||
if (prop in target)
|
|
||||||
delete target[prop]
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value !== null && typeof value === 'object')
|
|
||||||
target[prop as string] = cloneDeep(value)
|
|
||||||
else
|
|
||||||
target[prop as string] = value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
newNodes.forEach((newNode) => {
|
newNodes.forEach((newNode) => {
|
||||||
const oldNode = oldNodesMap.get(newNode.id)
|
const nodeContainer = this.getNodeContainer(newNode.id)
|
||||||
|
this.populateNodeContainer(nodeContainer, newNode)
|
||||||
if (!oldNode) {
|
|
||||||
// New node - create as nested structure
|
|
||||||
const nodeData: any = {
|
|
||||||
id: newNode.id,
|
|
||||||
type: newNode.type,
|
|
||||||
position: { ...newNode.position },
|
|
||||||
width: newNode.width,
|
|
||||||
height: newNode.height,
|
|
||||||
sourcePosition: newNode.sourcePosition,
|
|
||||||
targetPosition: newNode.targetPosition,
|
|
||||||
data: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
copyOptionalNodeProps(newNode, nodeData)
|
|
||||||
|
|
||||||
// Clone data properties, excluding private ones
|
|
||||||
Object.entries(newNode.data).forEach(([key, value]) => {
|
|
||||||
if (shouldSyncDataKey(key) && value !== undefined)
|
|
||||||
nodeData.data[key] = cloneDeep(value)
|
|
||||||
})
|
|
||||||
|
|
||||||
this.nodesMap.set(newNode.id, nodeData)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Get existing node from CRDT
|
|
||||||
const existingNode = this.nodesMap.get(newNode.id)
|
|
||||||
|
|
||||||
if (existingNode) {
|
|
||||||
// Create a deep copy to modify
|
|
||||||
const updatedNode = cloneDeep(existingNode)
|
|
||||||
|
|
||||||
// Update position only if changed
|
|
||||||
if (oldNode.position.x !== newNode.position.x || oldNode.position.y !== newNode.position.y)
|
|
||||||
updatedNode.position = { ...newNode.position }
|
|
||||||
|
|
||||||
// Update dimensions only if changed
|
|
||||||
if (oldNode.width !== newNode.width)
|
|
||||||
updatedNode.width = newNode.width
|
|
||||||
|
|
||||||
if (oldNode.height !== newNode.height)
|
|
||||||
updatedNode.height = newNode.height
|
|
||||||
|
|
||||||
// Ensure optional node props stay in sync
|
|
||||||
copyOptionalNodeProps(newNode, updatedNode)
|
|
||||||
|
|
||||||
// Ensure data object exists
|
|
||||||
if (!updatedNode.data)
|
|
||||||
updatedNode.data = {}
|
|
||||||
|
|
||||||
// Fine-grained update of data properties
|
|
||||||
const oldData = oldNode.data || {}
|
|
||||||
const newData = newNode.data || {}
|
|
||||||
|
|
||||||
// Only update changed properties in data
|
|
||||||
Object.entries(newData).forEach(([key, value]) => {
|
|
||||||
if (shouldSyncDataKey(key)) {
|
|
||||||
const oldValue = (oldData as any)[key]
|
|
||||||
if (!isEqual(oldValue, value))
|
|
||||||
updatedNode.data[key] = cloneDeep(value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Remove deleted properties from data
|
|
||||||
Object.keys(oldData).forEach((key) => {
|
|
||||||
if (shouldSyncDataKey(key) && !(key in newData))
|
|
||||||
delete updatedNode.data[key]
|
|
||||||
})
|
|
||||||
|
|
||||||
// Only update in CRDT if something actually changed
|
|
||||||
if (!isEqual(existingNode, updatedNode))
|
|
||||||
this.nodesMap.set(newNode.id, updatedNode)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Node exists locally but not in CRDT yet
|
|
||||||
const nodeData: any = {
|
|
||||||
id: newNode.id,
|
|
||||||
type: newNode.type,
|
|
||||||
position: { ...newNode.position },
|
|
||||||
width: newNode.width,
|
|
||||||
height: newNode.height,
|
|
||||||
sourcePosition: newNode.sourcePosition,
|
|
||||||
targetPosition: newNode.targetPosition,
|
|
||||||
data: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
copyOptionalNodeProps(newNode, nodeData)
|
|
||||||
|
|
||||||
Object.entries(newNode.data).forEach(([key, value]) => {
|
|
||||||
if (shouldSyncDataKey(key) && value !== undefined)
|
|
||||||
nodeData.data[key] = cloneDeep(value)
|
|
||||||
})
|
|
||||||
|
|
||||||
this.nodesMap.set(newNode.id, nodeData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -745,8 +754,9 @@ export class CollaborationManager {
|
|||||||
this.pendingInitialSync = false
|
this.pendingInitialSync = false
|
||||||
|
|
||||||
const updatedNodes = Array
|
const updatedNodes = Array
|
||||||
.from(this.nodesMap.values())
|
.from(this.nodesMap.keys())
|
||||||
.map((node: Node) => {
|
.map((nodeId) => {
|
||||||
|
const node = this.exportNode(nodeId as string)
|
||||||
const clonedNode: Node = {
|
const clonedNode: Node = {
|
||||||
...node,
|
...node,
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user