mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-17 05:01:02 +00:00
Compare commits
23 Commits
dev/remote
...
drjkl/cart
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
231184f0bf | ||
|
|
44e3f9a7b6 | ||
|
|
a631e7937f | ||
|
|
238da67eee | ||
|
|
5bad07f610 | ||
|
|
d4767e65de | ||
|
|
12992c51ed | ||
|
|
07c844aa6b | ||
|
|
a9537a7450 | ||
|
|
e03ffecf5b | ||
|
|
f91a42a4a7 | ||
|
|
27c72113db | ||
|
|
81860fc379 | ||
|
|
b7721f9247 | ||
|
|
86963c9435 | ||
|
|
8119bb45fb | ||
|
|
29c6a37e8b | ||
|
|
177535b1b5 | ||
|
|
c39151d653 | ||
|
|
ecedeb1515 | ||
|
|
90414f1da9 | ||
|
|
e421d1dc01 | ||
|
|
7e2fb8977c |
@@ -561,7 +561,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
vueNodeLifecycle.cleanup()
|
||||
vueNodeLifecycle.disposeNodeManagerAndSyncs()
|
||||
})
|
||||
function forwardPanEvent(e: PointerEvent) {
|
||||
if (
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useNodeDisplayStore } from '@/stores/nodeDisplayStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
@@ -316,3 +317,57 @@ describe('Nested promoted widget mapping', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Display store integration', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('registers node in display store on node add', () => {
|
||||
const graph = new LGraph()
|
||||
useGraphNodeManager(graph)
|
||||
|
||||
const node = new LGraphNode('test')
|
||||
node.title = 'My Node'
|
||||
graph.add(node)
|
||||
|
||||
const displayStore = useNodeDisplayStore()
|
||||
const state = displayStore.getNode(graph.rootGraph.id, String(node.id))
|
||||
expect(state).toMatchObject({
|
||||
id: String(node.id),
|
||||
title: 'My Node'
|
||||
})
|
||||
})
|
||||
|
||||
it('removes from display store on node removal', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
useGraphNodeManager(graph)
|
||||
|
||||
const displayStore = useNodeDisplayStore()
|
||||
expect(
|
||||
displayStore.getNode(graph.rootGraph.id, String(node.id))
|
||||
).toBeDefined()
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(
|
||||
displayStore.getNode(graph.rootGraph.id, String(node.id))
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updates vue node data when display store is updated', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const displayStore = useNodeDisplayStore()
|
||||
displayStore.updateNode(graph.rootGraph.id, String(node.id), {
|
||||
title: 'Updated Title'
|
||||
})
|
||||
|
||||
expect(vueNodeData.get(String(node.id))?.title).toBe('Updated Title')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { reactiveComputed } from '@vueuse/core'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
import { computed, reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
@@ -19,6 +19,7 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import { useNodeDisplayStore } from '@/stores/nodeDisplayStore'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
@@ -29,9 +30,7 @@ import type {
|
||||
LGraph,
|
||||
LGraphBadge,
|
||||
LGraphNode,
|
||||
LGraphTriggerAction,
|
||||
LGraphTriggerEvent,
|
||||
LGraphTriggerParam
|
||||
LGraphTriggerEvent
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
@@ -413,15 +412,25 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
|
||||
const apiNode = node.constructor?.nodeData?.api_node ?? false
|
||||
const badges = node.badges
|
||||
const nodeId = String(node.id)
|
||||
const nodeDisplayStore = useNodeDisplayStore()
|
||||
const presentationRef = computed(() => {
|
||||
const graphId = node.graph?.rootGraph?.id
|
||||
return graphId ? nodeDisplayStore.getNode(graphId, nodeId) : undefined
|
||||
})
|
||||
|
||||
return {
|
||||
id: String(node.id),
|
||||
title: typeof node.title === 'string' ? node.title : '',
|
||||
const data: VueNodeData = {
|
||||
id: nodeId,
|
||||
get title() {
|
||||
return presentationRef.value?.title ?? ''
|
||||
},
|
||||
type: nodeType,
|
||||
mode: node.mode || 0,
|
||||
get mode() {
|
||||
return presentationRef.value?.mode ?? 0
|
||||
},
|
||||
titleMode: node.title_mode,
|
||||
selected: node.selected || false,
|
||||
executing: false, // Will be updated separately based on execution state
|
||||
executing: false,
|
||||
subgraphId,
|
||||
apiNode,
|
||||
badges,
|
||||
@@ -429,18 +438,33 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
widgets: safeWidgets,
|
||||
inputs: reactiveInputs,
|
||||
outputs: node.outputs ? [...node.outputs] : undefined,
|
||||
flags: node.flags ? { ...node.flags } : undefined,
|
||||
color: node.color || undefined,
|
||||
bgcolor: node.bgcolor || undefined,
|
||||
get flags() {
|
||||
const f = presentationRef.value?.flags
|
||||
return f ? { ...f } : undefined
|
||||
},
|
||||
get color() {
|
||||
return presentationRef.value?.color
|
||||
},
|
||||
get bgcolor() {
|
||||
return presentationRef.value?.bgcolor
|
||||
},
|
||||
resizable: node.resizable,
|
||||
shape: node.shape,
|
||||
showAdvanced: node.showAdvanced
|
||||
get shape() {
|
||||
return presentationRef.value?.shape
|
||||
},
|
||||
get showAdvanced() {
|
||||
return presentationRef.value?.showAdvanced
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Get layout mutations composable
|
||||
const { createNode, deleteNode, setSource } = useLayoutMutations()
|
||||
const nodeDisplayStore = useNodeDisplayStore()
|
||||
const graphId = graph.rootGraph.id
|
||||
// Safe reactive data extracted from LiteGraph nodes
|
||||
const vueNodeData = reactive(new Map<string, VueNodeData>())
|
||||
|
||||
@@ -485,6 +509,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Remove deleted nodes
|
||||
for (const id of Array.from(vueNodeData.keys())) {
|
||||
if (!currentNodes.has(id)) {
|
||||
nodeDisplayStore.removeNode(graphId, id)
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
}
|
||||
@@ -526,6 +551,21 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
const nodePosition = { x: node.pos[0], y: node.pos[1] }
|
||||
const nodeSize = { width: node.size[0], height: node.size[1] }
|
||||
|
||||
nodeDisplayStore.registerNode(graphId, id, {
|
||||
id,
|
||||
title: typeof node.title === 'string' ? node.title : '',
|
||||
mode: node.mode || 0,
|
||||
shape: node.shape,
|
||||
showAdvanced: node.showAdvanced,
|
||||
color: node.color || undefined,
|
||||
bgcolor: node.bgcolor || undefined,
|
||||
flags: {
|
||||
collapsed: node.flags?.collapsed,
|
||||
pinned: node.flags?.pinned,
|
||||
ghost: node.flags?.ghost
|
||||
}
|
||||
})
|
||||
|
||||
// Skip layout creation if it already exists
|
||||
// (e.g. in-place node replacement where the old node's layout is reused for the new node with the same ID).
|
||||
const existingLayout = layoutStore.getNodeLayoutRef(id).value
|
||||
@@ -578,6 +618,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
setSource(LayoutSource.Canvas)
|
||||
void deleteNode(id)
|
||||
|
||||
nodeDisplayStore.removeNode(graphId, id)
|
||||
|
||||
// Clean up all tracking references
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
@@ -626,97 +668,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
handleNodeRemoved(node, originalOnNodeRemoved)
|
||||
}
|
||||
|
||||
const triggerHandlers: {
|
||||
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
|
||||
} = {
|
||||
'node:property:changed': (propertyEvent) => {
|
||||
const nodeId = String(propertyEvent.nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
|
||||
if (currentData) {
|
||||
switch (propertyEvent.property) {
|
||||
case 'title':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
title: String(propertyEvent.newValue)
|
||||
})
|
||||
break
|
||||
case 'flags.collapsed':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
flags: {
|
||||
...currentData.flags,
|
||||
collapsed: Boolean(propertyEvent.newValue)
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'flags.ghost':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
flags: {
|
||||
...currentData.flags,
|
||||
ghost: Boolean(propertyEvent.newValue)
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'flags.pinned':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
flags: {
|
||||
...currentData.flags,
|
||||
pinned: Boolean(propertyEvent.newValue)
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'mode':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
mode:
|
||||
typeof propertyEvent.newValue === 'number'
|
||||
? propertyEvent.newValue
|
||||
: 0
|
||||
})
|
||||
break
|
||||
case 'color':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
color:
|
||||
typeof propertyEvent.newValue === 'string'
|
||||
? propertyEvent.newValue
|
||||
: undefined
|
||||
})
|
||||
break
|
||||
case 'bgcolor':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
bgcolor:
|
||||
typeof propertyEvent.newValue === 'string'
|
||||
? propertyEvent.newValue
|
||||
: undefined
|
||||
})
|
||||
break
|
||||
case 'shape':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
shape:
|
||||
typeof propertyEvent.newValue === 'number'
|
||||
? propertyEvent.newValue
|
||||
: undefined
|
||||
})
|
||||
break
|
||||
case 'showAdvanced':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
showAdvanced: Boolean(propertyEvent.newValue)
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
'node:slot-errors:changed': (slotErrorsEvent) => {
|
||||
const triggerHandlers = {
|
||||
'node:slot-errors:changed': (slotErrorsEvent: {
|
||||
nodeId: string | number
|
||||
}) => {
|
||||
refreshNodeSlots(String(slotErrorsEvent.nodeId))
|
||||
},
|
||||
'node:slot-links:changed': (slotLinksEvent) => {
|
||||
'node:slot-links:changed': (slotLinksEvent: {
|
||||
nodeId: string | number
|
||||
slotType: NodeSlotType
|
||||
}) => {
|
||||
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
|
||||
refreshNodeSlots(String(slotLinksEvent.nodeId))
|
||||
}
|
||||
@@ -725,9 +686,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
|
||||
graph.onTrigger = (event: LGraphTriggerEvent) => {
|
||||
switch (event.type) {
|
||||
case 'node:property:changed':
|
||||
triggerHandlers['node:property:changed'](event)
|
||||
break
|
||||
case 'node:slot-errors:changed':
|
||||
triggerHandlers['node:slot-errors:changed'](event)
|
||||
break
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDisplayStore } from '@/stores/nodeDisplayStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
|
||||
@@ -62,6 +63,11 @@ function useVueNodeLifecycleIndividual() {
|
||||
const disposeNodeManagerAndSyncs = () => {
|
||||
if (!nodeManager.value) return
|
||||
|
||||
const graphId = comfyApp.canvas?.graph?.rootGraph.id
|
||||
if (graphId) {
|
||||
useNodeDisplayStore().clearGraph(graphId)
|
||||
}
|
||||
|
||||
try {
|
||||
nodeManager.value.cleanup()
|
||||
} catch {
|
||||
@@ -136,22 +142,13 @@ function useVueNodeLifecycleIndividual() {
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup function for component unmounting
|
||||
const cleanup = () => {
|
||||
if (nodeManager.value) {
|
||||
nodeManager.value.cleanup()
|
||||
nodeManager.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeManager,
|
||||
|
||||
// Lifecycle methods
|
||||
initializeNodeManager,
|
||||
disposeNodeManagerAndSyncs,
|
||||
setupEmptyGraphListener,
|
||||
cleanup
|
||||
setupEmptyGraphListener
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,4 +102,32 @@ describe('Clipboard UTF-8 base64 encoding/decoding', () => {
|
||||
// This demonstrates why we need TextEncoder - plain btoa fails
|
||||
expect(() => btoa(original)).toThrow()
|
||||
})
|
||||
|
||||
it('should round-trip node data with layout and presentation fields', () => {
|
||||
const nodeData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'LoadImage',
|
||||
pos: [100, 200],
|
||||
size: [300, 150],
|
||||
title: 'My Image Loader',
|
||||
mode: 0,
|
||||
flags: { collapsed: false },
|
||||
color: '#ff0000'
|
||||
}
|
||||
],
|
||||
links: []
|
||||
}
|
||||
const original = JSON.stringify(nodeData)
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
const parsed = JSON.parse(decoded)
|
||||
|
||||
expect(parsed.nodes[0].pos).toEqual([100, 200])
|
||||
expect(parsed.nodes[0].size).toEqual([300, 150])
|
||||
expect(parsed.nodes[0].title).toBe('My Image Loader')
|
||||
expect(parsed.nodes[0].color).toBe('#ff0000')
|
||||
expect(parsed.nodes[0].flags).toEqual({ collapsed: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -595,6 +595,50 @@ describe('usePaste', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass layout-bearing clipboard data to _deserializeItems', async () => {
|
||||
const clipboardData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'LoadImage',
|
||||
pos: [100, 200],
|
||||
size: [300, 150],
|
||||
title: 'Copied Node',
|
||||
mode: 0,
|
||||
flags: {}
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
reroutes: []
|
||||
}
|
||||
const encoded = btoa(JSON.stringify(clipboardData))
|
||||
const html = `<div data-metadata="${encoded}"></div>`
|
||||
|
||||
usePaste()
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/html', html)
|
||||
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCanvas._deserializeItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nodes: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pos: [100, 200],
|
||||
size: [300, 150],
|
||||
title: 'Copied Node'
|
||||
})
|
||||
])
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloneDataTransfer', () => {
|
||||
|
||||
@@ -175,7 +175,8 @@ function dynamicComboWidget(
|
||||
}
|
||||
}
|
||||
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
const computed = node.computeSize([...node.size])
|
||||
node.size = [node.size[0], computed[1]]
|
||||
if (!node.graph) return
|
||||
node._setConcreteSlots()
|
||||
node.arrange()
|
||||
@@ -523,7 +524,8 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
||||
for (const widget of remove(node.widgets, (w) => w.name === widgetName))
|
||||
widget.onRemove?.()
|
||||
}
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
const computed = node.computeSize([...node.size])
|
||||
node.size = [node.size[0], computed[1]]
|
||||
}
|
||||
|
||||
function withComfyAutogrow(node: LGraphNode): asserts node is AutogrowNode {
|
||||
|
||||
@@ -336,11 +336,10 @@ export class PrimitiveNode extends LGraphNode {
|
||||
|
||||
if (!recreating) {
|
||||
const sz = this.computeSize()
|
||||
if (this.size[0] < sz[0]) {
|
||||
this.size[0] = sz[0]
|
||||
}
|
||||
if (this.size[1] < sz[1]) {
|
||||
this.size[1] = sz[1]
|
||||
const newWidth = Math.max(this.size[0], sz[0])
|
||||
const newHeight = Math.max(this.size[1], sz[1])
|
||||
if (newWidth !== this.size[0] || newHeight !== this.size[1]) {
|
||||
this.size = [newWidth, newHeight]
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
|
||||
@@ -79,11 +79,6 @@ import type {
|
||||
import { getAllNestedItems } from './utils/collections'
|
||||
import { deduplicateSubgraphNodeIds } from './utils/subgraphDeduplication'
|
||||
|
||||
export type {
|
||||
LGraphTriggerAction,
|
||||
LGraphTriggerParam
|
||||
} from './types/graphTriggers'
|
||||
|
||||
export type RendererType = 'LG' | 'Vue'
|
||||
|
||||
export interface LGraphState {
|
||||
@@ -1330,8 +1325,7 @@ export class LGraph
|
||||
// Convert to discriminated union format for typed handlers
|
||||
const validEventTypes = new Set([
|
||||
'node:slot-links:changed',
|
||||
'node:slot-errors:changed',
|
||||
'node:property:changed'
|
||||
'node:slot-errors:changed'
|
||||
])
|
||||
|
||||
if (validEventTypes.has(action) && param && typeof param === 'object') {
|
||||
|
||||
@@ -7,6 +7,10 @@ import type {
|
||||
ExportedSubgraph,
|
||||
ISerialisedNode
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
import {
|
||||
extractLayoutFromSerialized,
|
||||
extractPresentationFromSerialized
|
||||
} from '@/renderer/core/layout/persistence/layoutPersistenceAdapter'
|
||||
|
||||
function createSerialisedNode(
|
||||
id: number,
|
||||
@@ -100,3 +104,80 @@ describe('remapClipboardSubgraphNodeIds', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Serialization layout field preservation', () => {
|
||||
it('serialized node preserves pos and size arrays', () => {
|
||||
const node = new LGraphNode('test')
|
||||
node.pos = [100, 200]
|
||||
node.size = [300, 400]
|
||||
|
||||
const serialized = node.serialize()
|
||||
|
||||
expect(serialized.pos).toEqual([100, 200])
|
||||
expect(serialized.size).toEqual([300, 400])
|
||||
})
|
||||
|
||||
it('serialized node preserves presentation fields', () => {
|
||||
const node = new LGraphNode('test')
|
||||
node.title = 'Custom Title'
|
||||
node.mode = 2
|
||||
node.color = '#ff0000'
|
||||
node.bgcolor = '#00ff00'
|
||||
|
||||
const serialized = node.serialize()
|
||||
|
||||
expect(serialized.title).toBe('Custom Title')
|
||||
expect(serialized.mode).toBe(2)
|
||||
expect(serialized.color).toBe('#ff0000')
|
||||
expect(serialized.bgcolor).toBe('#00ff00')
|
||||
})
|
||||
|
||||
it('serialized node preserves flags', () => {
|
||||
const node = new LGraphNode('test')
|
||||
node.flags.collapsed = true
|
||||
node.flags.pinned = true
|
||||
|
||||
const serialized = node.serialize()
|
||||
|
||||
expect(serialized.flags?.collapsed).toBe(true)
|
||||
expect(serialized.flags?.pinned).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layout persistence adapter round-trip', () => {
|
||||
it('extractLayoutFromSerialized uses provided zIndex', () => {
|
||||
const serializedNode: ISerialisedNode = {
|
||||
id: 42,
|
||||
type: 'test',
|
||||
pos: [10, 20],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
}
|
||||
|
||||
const layout = extractLayoutFromSerialized(serializedNode, 7)
|
||||
expect(layout.zIndex).toBe(7)
|
||||
expect(layout.id).toBe('42')
|
||||
})
|
||||
|
||||
it('extractPresentationFromSerialized handles missing optional fields', () => {
|
||||
const serializedNode: ISerialisedNode = {
|
||||
id: 1,
|
||||
type: 'test',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
}
|
||||
|
||||
const presentation = extractPresentationFromSerialized(serializedNode)
|
||||
expect(presentation.title).toBe('')
|
||||
expect(presentation.mode).toBe(0)
|
||||
expect(presentation.color).toBeUndefined()
|
||||
expect(presentation.bgcolor).toBeUndefined()
|
||||
expect(presentation.flags.collapsed).toBeUndefined()
|
||||
expect(presentation.flags.pinned).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MovingInputLink } from '@/lib/litegraph/src/canvas/MovingInputLink'
|
||||
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
@@ -3620,13 +3621,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.deselect(node)
|
||||
this.graph?.remove(node)
|
||||
} else {
|
||||
delete node.flags.ghost
|
||||
this.graph?.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'flags.ghost',
|
||||
oldValue: true,
|
||||
newValue: false
|
||||
})
|
||||
node.flags.ghost = undefined
|
||||
|
||||
this.state.selectionChanged = true
|
||||
this.onSelectionChange?.(this.selected_nodes)
|
||||
@@ -3968,7 +3963,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
): ClipboardPasteResult | undefined {
|
||||
const data = localStorage.getItem('litegrapheditor_clipboard')
|
||||
if (!data) return
|
||||
return this._deserializeItems(JSON.parse(data), options)
|
||||
let parsed: ClipboardItems
|
||||
try {
|
||||
parsed = JSON.parse(data)
|
||||
} catch {
|
||||
console.warn('Failed to parse clipboard data')
|
||||
return
|
||||
}
|
||||
return this._deserializeItems(parsed, options)
|
||||
}
|
||||
|
||||
_deserializeItems(
|
||||
@@ -4133,7 +4135,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust positions - use move/setPos to ensure layout store is updated
|
||||
const dx = position[0] - offsetX
|
||||
const dy = position[1] - offsetY
|
||||
for (const item of created) {
|
||||
@@ -4722,6 +4723,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
graph._nodes.splice(i, 1)
|
||||
graph._nodes.push(node)
|
||||
|
||||
const { bringNodeToFront } = useLayoutMutations()
|
||||
bringNodeToFront(String(node.id))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4736,6 +4740,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
graph._nodes.splice(i, 1)
|
||||
graph._nodes.unshift(node)
|
||||
|
||||
const { sendNodeToBack } = useLayoutMutations()
|
||||
sendNodeToBack(String(node.id))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -7674,8 +7681,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
value = Number(value)
|
||||
}
|
||||
if (type == 'array' || type == 'object') {
|
||||
// @ts-expect-error JSON.parse doesn't care.
|
||||
value = JSON.parse(value)
|
||||
try {
|
||||
// @ts-expect-error JSON.parse doesn't care.
|
||||
value = JSON.parse(value)
|
||||
} catch {
|
||||
console.warn(`Failed to parse property "${property}" as ${type}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
node.properties[property] = value
|
||||
if (node.graph) {
|
||||
@@ -8130,7 +8142,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
node.onShowCustomPanelInfo?.(panel)
|
||||
|
||||
// clear
|
||||
panel.footer.innerHTML = ''
|
||||
panel.footer.replaceChildren()
|
||||
panel
|
||||
.addButton('Delete', function () {
|
||||
if (node.block_delete) return
|
||||
@@ -8146,9 +8158,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
panel.classList.remove('settings')
|
||||
panel.classList.add('centered')
|
||||
|
||||
panel.alt_content.innerHTML = "<textarea class='code'></textarea>"
|
||||
const textarea: HTMLTextAreaElement =
|
||||
panel.alt_content.querySelector('textarea')!
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.className = 'code'
|
||||
panel.alt_content.replaceChildren(textarea)
|
||||
const fDoneWith = function () {
|
||||
panel.toggleAltContent(false)
|
||||
panel.toggleFooterVisibility(true)
|
||||
|
||||
143
src/lib/litegraph/src/LGraphGroup.scopeInvariants.test.ts
Normal file
143
src/lib/litegraph/src/LGraphGroup.scopeInvariants.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Group Scope Invariant Tests
|
||||
*
|
||||
* Verifies that LGraphGroup geometry and state remain legacy-owned
|
||||
* (LiteGraph-authoritative) and are not partially centralized in
|
||||
* the layout store domain.
|
||||
*
|
||||
* Decision record: temp/node-layout-ssot-group-scope.md
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphGroup } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { LayoutOperation } from '@/renderer/core/layout/types'
|
||||
|
||||
describe('LGraphGroup scope invariants', () => {
|
||||
describe('layout store exclusion boundary', () => {
|
||||
it('layoutStore operation types do not include group operations', () => {
|
||||
const nodeOperationTypes: LayoutOperation['type'][] = [
|
||||
'moveNode',
|
||||
'resizeNode',
|
||||
'setNodeZIndex',
|
||||
'createNode',
|
||||
'deleteNode',
|
||||
'setNodeVisibility',
|
||||
'batchUpdateBounds',
|
||||
'createLink',
|
||||
'deleteLink',
|
||||
'createReroute',
|
||||
'deleteReroute',
|
||||
'moveReroute'
|
||||
]
|
||||
|
||||
for (const opType of nodeOperationTypes) {
|
||||
expect(opType).not.toContain('group')
|
||||
expect(opType).not.toContain('Group')
|
||||
}
|
||||
})
|
||||
|
||||
it('layoutStore has no group-related public methods', () => {
|
||||
const storeKeys = Object.getOwnPropertyNames(
|
||||
Object.getPrototypeOf(layoutStore)
|
||||
)
|
||||
|
||||
const groupMethods = storeKeys.filter(
|
||||
(key) => key.toLowerCase().includes('group') && key !== 'constructor'
|
||||
)
|
||||
|
||||
expect(groupMethods).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('group geometry remains self-contained', () => {
|
||||
it('group move does not interact with layoutStore', () => {
|
||||
const group = new LGraphGroup('test')
|
||||
group.pos = [100, 200]
|
||||
|
||||
const initialPos = [...group.pos]
|
||||
group.move(50, 30)
|
||||
|
||||
expect(group.pos[0]).toBe(initialPos[0] + 50)
|
||||
expect(group.pos[1]).toBe(initialPos[1] + 30)
|
||||
})
|
||||
|
||||
it('group resize does not interact with layoutStore', () => {
|
||||
const group = new LGraphGroup('test')
|
||||
group.size = [300, 200]
|
||||
|
||||
expect(group.resize(400, 300)).toBe(true)
|
||||
expect(group.size[0]).toBe(400)
|
||||
expect(group.size[1]).toBe(300)
|
||||
})
|
||||
|
||||
it('group serialization round-trips without store involvement', () => {
|
||||
const group = new LGraphGroup('Round Trip', 42)
|
||||
group.pos = [150, 250]
|
||||
group.size = [500, 400]
|
||||
group.color = '#f00'
|
||||
group.font_size = 32
|
||||
|
||||
const serialized = group.serialize()
|
||||
const restored = new LGraphGroup()
|
||||
restored.configure(serialized)
|
||||
|
||||
expect(restored.id).toBe(42)
|
||||
expect(restored.title).toBe('Round Trip')
|
||||
expect(restored.pos[0]).toBe(150)
|
||||
expect(restored.pos[1]).toBe(250)
|
||||
expect(restored.size[0]).toBe(500)
|
||||
expect(restored.size[1]).toBe(400)
|
||||
expect(restored.color).toBe('#f00')
|
||||
expect(restored.font_size).toBe(32)
|
||||
})
|
||||
})
|
||||
|
||||
describe('group lifecycle is graph-owned', () => {
|
||||
it('graph.add(group) does not create layoutStore entries', () => {
|
||||
layoutStore.initializeFromLiteGraph([])
|
||||
const graph = new LGraph()
|
||||
const group = new LGraphGroup('test')
|
||||
group.pos = [0, 0]
|
||||
group.size = [200, 200]
|
||||
|
||||
graph.add(group)
|
||||
|
||||
const allNodes = layoutStore.getAllNodes()
|
||||
expect(allNodes.value.size).toBe(0)
|
||||
})
|
||||
|
||||
it('graph.remove(group) does not affect layoutStore', () => {
|
||||
layoutStore.initializeFromLiteGraph([])
|
||||
const graph = new LGraph()
|
||||
const group = new LGraphGroup('test')
|
||||
graph.add(group)
|
||||
|
||||
graph.remove(group)
|
||||
|
||||
const allNodes = layoutStore.getAllNodes()
|
||||
expect(allNodes.value.size).toBe(0)
|
||||
})
|
||||
|
||||
it('pinned group prevents move without store involvement', () => {
|
||||
const group = new LGraphGroup('pinned test')
|
||||
group.pos = [100, 100]
|
||||
group.pin(true)
|
||||
|
||||
group.move(50, 50)
|
||||
|
||||
expect(group.pos[0]).toBe(100)
|
||||
expect(group.pos[1]).toBe(100)
|
||||
})
|
||||
|
||||
it('pinned group prevents resize without store involvement', () => {
|
||||
const group = new LGraphGroup('pinned test')
|
||||
group.size = [300, 200]
|
||||
group.pin(true)
|
||||
|
||||
expect(group.resize(500, 400)).toBe(false)
|
||||
expect(group.size[0]).toBe(300)
|
||||
expect(group.size[1]).toBe(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
233
src/lib/litegraph/src/LGraphNode.geometryMutationGuards.test.ts
Normal file
233
src/lib/litegraph/src/LGraphNode.geometryMutationGuards.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
getNodeLayoutRef: vi.fn(() => ({
|
||||
value: {
|
||||
id: '1',
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 50 },
|
||||
zIndex: 0,
|
||||
visible: true,
|
||||
bounds: { x: 0, y: 0, width: 100, height: 50 }
|
||||
}
|
||||
})),
|
||||
applyOperation: vi.fn(),
|
||||
getCurrentSource: vi.fn(() => 'canvas'),
|
||||
getCurrentActor: vi.fn(() => 'test'),
|
||||
setSource: vi.fn(),
|
||||
setActor: vi.fn(),
|
||||
batchUpdateNodeBounds: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('LGraphNode geometry mutation guards', () => {
|
||||
let node: LGraphNode
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
Object.assign(LiteGraph, {
|
||||
NODE_TITLE_HEIGHT: 20,
|
||||
NODE_SLOT_HEIGHT: 15,
|
||||
NODE_TEXT_SIZE: 14
|
||||
})
|
||||
|
||||
node = new LGraphNode('TestNode')
|
||||
node.id = 1
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('preserves constructor title value', () => {
|
||||
expect(node.title).toBe('TestNode')
|
||||
})
|
||||
|
||||
describe('pos setter triggers store mutation', () => {
|
||||
it('calls moveNode on layoutStore via pos setter', () => {
|
||||
node.pos = [200, 300]
|
||||
|
||||
expect(layoutStore.applyOperation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'moveNode',
|
||||
nodeId: '1',
|
||||
position: { x: 200, y: 300 }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('calls moveNode on layoutStore via setPos', () => {
|
||||
node.setPos(400, 500)
|
||||
|
||||
expect(layoutStore.applyOperation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'moveNode',
|
||||
nodeId: '1',
|
||||
position: { x: 400, y: 500 }
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('size setter triggers store mutation', () => {
|
||||
it('calls resizeNode on layoutStore via size setter', () => {
|
||||
node.size = [250, 150]
|
||||
|
||||
expect(layoutStore.applyOperation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'resizeNode',
|
||||
nodeId: '1',
|
||||
size: { width: 250, height: 150 }
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyStoreProjection bypasses store mutations', () => {
|
||||
it('updates backing arrays without triggering store writes', () => {
|
||||
node.applyStoreProjection({ x: 300, y: 400 }, { width: 500, height: 200 })
|
||||
|
||||
expect(node.pos[0]).toBe(300)
|
||||
expect(node.pos[1]).toBe(400)
|
||||
expect(node.size[0]).toBe(500)
|
||||
expect(node.size[1]).toBe(200)
|
||||
|
||||
// Must NOT have triggered any store operations
|
||||
expect(layoutStore.applyOperation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns true when values changed', () => {
|
||||
const changed = node.applyStoreProjection(
|
||||
{ x: 999, y: 888 },
|
||||
{ width: 777, height: 666 }
|
||||
)
|
||||
expect(changed).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when values are unchanged', () => {
|
||||
node.applyStoreProjection({ x: 100, y: 200 }, { width: 300, height: 400 })
|
||||
vi.clearAllMocks()
|
||||
|
||||
const changed = node.applyStoreProjection(
|
||||
{ x: 100, y: 200 },
|
||||
{ width: 300, height: 400 }
|
||||
)
|
||||
expect(changed).toBe(false)
|
||||
expect(layoutStore.applyOperation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onResize when size changes', () => {
|
||||
const onResize = vi.fn()
|
||||
node.onResize = onResize
|
||||
|
||||
node.applyStoreProjection({ x: 0, y: 0 }, { width: 999, height: 888 })
|
||||
|
||||
expect(onResize).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not call onResize when only position changes', () => {
|
||||
const onResize = vi.fn()
|
||||
node.onResize = onResize
|
||||
|
||||
// Set initial size
|
||||
node.applyStoreProjection({ x: 0, y: 0 }, { width: 100, height: 50 })
|
||||
onResize.mockClear()
|
||||
|
||||
// Change only position
|
||||
node.applyStoreProjection({ x: 999, y: 888 }, { width: 100, height: 50 })
|
||||
|
||||
expect(onResize).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('presentation property setters avoid graph trigger emissions', () => {
|
||||
it('title setter does not emit graph trigger events', () => {
|
||||
const graph = { trigger: vi.fn() }
|
||||
node.graph = graph as unknown as LGraphNode['graph']
|
||||
|
||||
node.title = 'New Title'
|
||||
|
||||
expect(graph.trigger).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mode setter does not emit graph trigger events', () => {
|
||||
const graph = { trigger: vi.fn() }
|
||||
node.graph = graph as unknown as LGraphNode['graph']
|
||||
|
||||
node.mode = 2
|
||||
|
||||
expect(graph.trigger).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shape setter does not emit graph trigger events', () => {
|
||||
const graph = { trigger: vi.fn() }
|
||||
node.graph = graph as unknown as LGraphNode['graph']
|
||||
|
||||
node.shape = 'round'
|
||||
|
||||
expect(graph.trigger).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('color setter does not emit graph trigger events', () => {
|
||||
const graph = { trigger: vi.fn() }
|
||||
node.graph = graph as unknown as LGraphNode['graph']
|
||||
|
||||
node.color = '#ffffff'
|
||||
|
||||
expect(graph.trigger).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bgcolor setter does not emit graph trigger events', () => {
|
||||
const graph = { trigger: vi.fn() }
|
||||
node.graph = graph as unknown as LGraphNode['graph']
|
||||
|
||||
node.bgcolor = '#222222'
|
||||
|
||||
expect(graph.trigger).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('showAdvanced setter does not emit graph trigger events', () => {
|
||||
const graph = { trigger: vi.fn() }
|
||||
node.graph = graph as unknown as LGraphNode['graph']
|
||||
|
||||
node.showAdvanced = true
|
||||
|
||||
expect(graph.trigger).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('flags.ghost mutation does not emit graph trigger events', () => {
|
||||
const graph = { trigger: vi.fn() }
|
||||
node.graph = graph as unknown as LGraphNode['graph']
|
||||
|
||||
node.flags.ghost = true
|
||||
|
||||
expect(graph.trigger).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('title setter does NOT fire when value is unchanged', () => {
|
||||
const graph = { trigger: vi.fn() }
|
||||
node.graph = graph as unknown as LGraphNode['graph']
|
||||
|
||||
node.title = 'Same'
|
||||
graph.trigger.mockClear()
|
||||
|
||||
node.title = 'Same'
|
||||
expect(graph.trigger).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mode setter does NOT fire when value is unchanged', () => {
|
||||
const graph = { trigger: vi.fn() }
|
||||
node.graph = graph as unknown as LGraphNode['graph']
|
||||
|
||||
node.mode = 4
|
||||
graph.trigger.mockClear()
|
||||
|
||||
node.mode = 4
|
||||
expect(graph.trigger).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
206
src/lib/litegraph/src/LGraphNode.presentationTracking.test.ts
Normal file
206
src/lib/litegraph/src/LGraphNode.presentationTracking.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeDisplayStore } from '@/stores/nodeDisplayStore'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { RenderShape } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
getNodeLayoutRef: vi.fn(() => ({
|
||||
value: {
|
||||
id: '1',
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 50 },
|
||||
zIndex: 0,
|
||||
visible: true,
|
||||
bounds: { x: 0, y: 0, width: 100, height: 50 }
|
||||
}
|
||||
})),
|
||||
applyOperation: vi.fn(),
|
||||
getCurrentSource: vi.fn(() => 'canvas'),
|
||||
getCurrentActor: vi.fn(() => 'test'),
|
||||
setSource: vi.fn(),
|
||||
setActor: vi.fn(),
|
||||
batchUpdateNodeBounds: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const TEST_GRAPH_ID = 'test-graph-id' as UUID
|
||||
|
||||
function attachMockGraph(node: LGraphNode): void {
|
||||
node.graph = {
|
||||
_version: 0,
|
||||
rootGraph: { id: TEST_GRAPH_ID }
|
||||
} as unknown as LGraphNode['graph']
|
||||
}
|
||||
|
||||
describe('LGraphNode presentation tracking', () => {
|
||||
let node: LGraphNode
|
||||
let store: ReturnType<typeof useNodeDisplayStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useNodeDisplayStore()
|
||||
|
||||
Object.assign(LiteGraph, {
|
||||
NODE_TITLE_HEIGHT: 20,
|
||||
NODE_SLOT_HEIGHT: 15,
|
||||
NODE_TEXT_SIZE: 14
|
||||
})
|
||||
|
||||
node = new LGraphNode('TestNode')
|
||||
node.id = 1
|
||||
attachMockGraph(node)
|
||||
|
||||
store.registerNode(TEST_GRAPH_ID, '1', {
|
||||
id: '1',
|
||||
title: 'TestNode',
|
||||
mode: 0,
|
||||
flags: {}
|
||||
})
|
||||
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('flag mutation → store sync', () => {
|
||||
it('collapsed = true propagates to store', () => {
|
||||
node.flags.collapsed = true
|
||||
expect(store.getNode(TEST_GRAPH_ID, '1')?.flags.collapsed).toBe(true)
|
||||
})
|
||||
|
||||
it('pinned = true propagates to store', () => {
|
||||
node.flags.pinned = true
|
||||
expect(store.getNode(TEST_GRAPH_ID, '1')?.flags.pinned).toBe(true)
|
||||
})
|
||||
|
||||
it('ghost = true propagates to store', () => {
|
||||
node.flags.ghost = true
|
||||
expect(store.getNode(TEST_GRAPH_ID, '1')?.flags.ghost).toBe(true)
|
||||
})
|
||||
|
||||
it('setting flag back to undefined propagates to store', () => {
|
||||
node.flags.collapsed = true
|
||||
expect(store.getNode(TEST_GRAPH_ID, '1')?.flags.collapsed).toBe(true)
|
||||
|
||||
node.flags.collapsed = undefined
|
||||
expect(store.getNode(TEST_GRAPH_ID, '1')?.flags.collapsed).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pinned → resizable side effect', () => {
|
||||
it('pinned = true sets resizable to false', () => {
|
||||
node.flags.pinned = true
|
||||
expect(node.resizable).toBe(false)
|
||||
})
|
||||
|
||||
it('pinned = false restores resizable to true', () => {
|
||||
node.flags.pinned = true
|
||||
node.flags.pinned = false
|
||||
expect(node.resizable).toBe(true)
|
||||
})
|
||||
|
||||
it('pinned = undefined restores resizable to true', () => {
|
||||
node.flags.pinned = true
|
||||
node.flags.pinned = undefined
|
||||
expect(node.resizable).toBe(true)
|
||||
})
|
||||
|
||||
it('pin() method toggles pinned and resizable', () => {
|
||||
node.pin(true)
|
||||
expect(node.pinned).toBe(true)
|
||||
expect(node.resizable).toBe(false)
|
||||
|
||||
node.pin(false)
|
||||
expect(node.pinned).toBe(false)
|
||||
expect(node.resizable).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('serialization round-trip for flags', () => {
|
||||
it('ghost = true appears in serialized flags', () => {
|
||||
node.flags.ghost = true
|
||||
const serialized = node.serialize()
|
||||
expect(serialized.flags.ghost).toBe(true)
|
||||
})
|
||||
|
||||
it('ghost set then cleared omits key from serialized flags', () => {
|
||||
node.flags.ghost = true
|
||||
node.flags.ghost = undefined
|
||||
const serialized = node.serialize()
|
||||
expect('ghost' in serialized.flags).toBe(false)
|
||||
})
|
||||
|
||||
it('pinned set then cleared omits key from serialized flags', () => {
|
||||
node.flags.pinned = true
|
||||
node.flags.pinned = undefined
|
||||
const serialized = node.serialize()
|
||||
expect('pinned' in serialized.flags).toBe(false)
|
||||
})
|
||||
|
||||
it('collapsed = true is serialized', () => {
|
||||
node.flags.collapsed = true
|
||||
const serialized = node.serialize()
|
||||
expect(serialized.flags.collapsed).toBe(true)
|
||||
})
|
||||
|
||||
it('collapsed = false is serialized (false is still a value)', () => {
|
||||
node.flags.collapsed = true
|
||||
node.flags.collapsed = false
|
||||
const serialized = node.serialize()
|
||||
expect(serialized.flags.collapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('collapsed = undefined omits key from serialized flags', () => {
|
||||
node.flags.collapsed = true
|
||||
node.flags.collapsed = undefined
|
||||
const serialized = node.serialize()
|
||||
expect('collapsed' in serialized.flags).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('presentation setter → store sync', () => {
|
||||
it('title setter syncs to store', () => {
|
||||
node.title = 'New'
|
||||
expect(store.getNode(TEST_GRAPH_ID, '1')?.title).toBe('New')
|
||||
})
|
||||
|
||||
it('mode setter syncs to store', () => {
|
||||
node.mode = 2
|
||||
expect(store.getNode(TEST_GRAPH_ID, '1')?.mode).toBe(2)
|
||||
})
|
||||
|
||||
it('color setter syncs to store', () => {
|
||||
node.color = '#ff0000'
|
||||
expect(store.getNode(TEST_GRAPH_ID, '1')?.color).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('bgcolor setter syncs to store', () => {
|
||||
node.bgcolor = '#00ff00'
|
||||
expect(store.getNode(TEST_GRAPH_ID, '1')?.bgcolor).toBe('#00ff00')
|
||||
})
|
||||
|
||||
it('shape setter converts string and syncs to store', () => {
|
||||
node.shape = 'round'
|
||||
expect(store.getNode(TEST_GRAPH_ID, '1')?.shape).toBe(RenderShape.ROUND)
|
||||
})
|
||||
|
||||
it('showAdvanced setter syncs to store', () => {
|
||||
node.showAdvanced = true
|
||||
expect(store.getNode(TEST_GRAPH_ID, '1')?.showAdvanced).toBe(true)
|
||||
})
|
||||
|
||||
it('same-value title assignment does not update store', () => {
|
||||
const updateSpy = vi.spyOn(store, 'updateNode')
|
||||
|
||||
node.title = 'X'
|
||||
expect(store.getNode(TEST_GRAPH_ID, '1')?.title).toBe('X')
|
||||
expect(updateSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
node.title = 'X'
|
||||
expect(updateSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,19 @@
|
||||
import { toValue } from 'vue'
|
||||
|
||||
import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
|
||||
import {
|
||||
calculateInputSlotPosFromSlot,
|
||||
getSlotPosition
|
||||
} from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import {
|
||||
extractLayoutFromSerialized,
|
||||
extractPresentationFromSerialized
|
||||
} from '@/renderer/core/layout/persistence/layoutPersistenceAdapter'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { useNodeDisplayStore } from '@/stores/nodeDisplayStore'
|
||||
import type { NodeDisplayUpdate } from '@/stores/nodeDisplayStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import type { ColorAdjustOptions } from '@/utils/colorUtil'
|
||||
import {
|
||||
@@ -255,7 +261,84 @@ export class LGraphNode
|
||||
static keepAllLinksOnBypass: boolean = false
|
||||
|
||||
/** The title text of the node. */
|
||||
title: string
|
||||
private _title: string = ''
|
||||
|
||||
private _mode: LGraphEventMode = LGraphEventMode.ALWAYS
|
||||
private _color?: string
|
||||
private _bgcolor?: string
|
||||
private _showAdvanced?: boolean
|
||||
private _flags: INodeFlags = {}
|
||||
|
||||
private applyPresentationChange(
|
||||
oldValue: unknown,
|
||||
newValue: unknown,
|
||||
update: NodeDisplayUpdate
|
||||
): void {
|
||||
if (oldValue === newValue) return
|
||||
|
||||
const graphId = this.graph?.rootGraph?.id
|
||||
if (!graphId) return
|
||||
useNodeDisplayStore().updateNode(graphId, String(this.id), update)
|
||||
}
|
||||
|
||||
private setTrackedFlagEnumerable(
|
||||
flag: keyof Pick<INodeFlags, 'collapsed' | 'pinned' | 'ghost'>,
|
||||
value: boolean | undefined
|
||||
): void {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(this._flags, flag)
|
||||
const shouldBeEnumerable = value !== undefined
|
||||
|
||||
if (!descriptor || descriptor.enumerable === shouldBeEnumerable) return
|
||||
|
||||
Object.defineProperty(this._flags, flag, {
|
||||
...descriptor,
|
||||
enumerable: shouldBeEnumerable
|
||||
})
|
||||
}
|
||||
|
||||
private ensureTrackedFlagProperty(
|
||||
flag: keyof Pick<INodeFlags, 'collapsed' | 'pinned' | 'ghost'>
|
||||
): void {
|
||||
const current = this._flags[flag]
|
||||
let value =
|
||||
typeof current === 'boolean' || current === undefined
|
||||
? current
|
||||
: undefined
|
||||
|
||||
Object.defineProperty(this._flags, flag, {
|
||||
get: () => value,
|
||||
set: (newValue: boolean | undefined) => {
|
||||
const oldValue = value
|
||||
if (oldValue === newValue) return
|
||||
|
||||
value = newValue
|
||||
this.setTrackedFlagEnumerable(flag, newValue)
|
||||
|
||||
const flagsUpdate: NonNullable<NodeDisplayUpdate['flags']> = {}
|
||||
flagsUpdate[flag] = newValue
|
||||
|
||||
this.applyPresentationChange(oldValue, newValue, { flags: flagsUpdate })
|
||||
},
|
||||
enumerable: value !== undefined,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
private initializeTrackedFlags(): void {
|
||||
this.ensureTrackedFlagProperty('collapsed')
|
||||
this.ensureTrackedFlagProperty('pinned')
|
||||
this.ensureTrackedFlagProperty('ghost')
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this._title
|
||||
}
|
||||
|
||||
set title(value: string) {
|
||||
const oldValue = this._title
|
||||
this._title = value
|
||||
this.applyPresentationChange(oldValue, value, { title: value })
|
||||
}
|
||||
/**
|
||||
* The font style used to render the node's title text.
|
||||
*/
|
||||
@@ -282,11 +365,17 @@ export class LGraphNode
|
||||
|
||||
properties: Dictionary<NodeProperty | undefined> = {}
|
||||
properties_info: INodePropertyInfo[] = []
|
||||
flags: INodeFlags = {}
|
||||
widgets?: IBaseWidget[]
|
||||
|
||||
/** Property manager for this node */
|
||||
changeTracker: LGraphNodeProperties
|
||||
get flags(): INodeFlags {
|
||||
return this._flags
|
||||
}
|
||||
|
||||
set flags(value: INodeFlags) {
|
||||
this._flags = value || {}
|
||||
this.initializeTrackedFlags()
|
||||
}
|
||||
|
||||
widgets?: IBaseWidget[]
|
||||
|
||||
/**
|
||||
* The amount of space available for widgets to grow into.
|
||||
@@ -298,19 +387,46 @@ export class LGraphNode
|
||||
|
||||
/** Execution order, automatically computed during run @see {@link LGraph.computeExecutionOrder} */
|
||||
order: number = 0
|
||||
mode: LGraphEventMode = LGraphEventMode.ALWAYS
|
||||
|
||||
get mode(): LGraphEventMode {
|
||||
return this._mode
|
||||
}
|
||||
|
||||
set mode(value: LGraphEventMode) {
|
||||
const oldValue = this._mode
|
||||
this._mode = value
|
||||
this.applyPresentationChange(oldValue, value, { mode: value })
|
||||
}
|
||||
last_serialization?: ISerialisedNode
|
||||
serialize_widgets?: boolean
|
||||
/**
|
||||
* The overridden fg color used to render the node.
|
||||
* @see {@link renderingColor}
|
||||
*/
|
||||
color?: string
|
||||
get color(): string | undefined {
|
||||
return this._color
|
||||
}
|
||||
|
||||
set color(value: string | undefined) {
|
||||
const oldValue = this._color
|
||||
this._color = value
|
||||
this.applyPresentationChange(oldValue, value, { color: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* The overridden bg color used to render the node.
|
||||
* @see {@link renderingBgColor}
|
||||
*/
|
||||
bgcolor?: string
|
||||
get bgcolor(): string | undefined {
|
||||
return this._bgcolor
|
||||
}
|
||||
|
||||
set bgcolor(value: string | undefined) {
|
||||
const oldValue = this._bgcolor
|
||||
this._bgcolor = value
|
||||
this.applyPresentationChange(oldValue, value, { bgcolor: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* The overridden box color used to render the node.
|
||||
* @see {@link renderingBoxColor}
|
||||
@@ -425,7 +541,15 @@ export class LGraphNode
|
||||
_shape?: RenderShape
|
||||
mouseOver?: IMouseOverData
|
||||
redraw_on_mouse?: boolean
|
||||
resizable?: boolean
|
||||
private _resizable?: boolean
|
||||
|
||||
get resizable(): boolean {
|
||||
return !this.pinned && this._resizable !== false
|
||||
}
|
||||
|
||||
set resizable(value: boolean | undefined) {
|
||||
this._resizable = value
|
||||
}
|
||||
clonable?: boolean
|
||||
_relative_id?: number
|
||||
clip_area?: boolean
|
||||
@@ -434,7 +558,18 @@ export class LGraphNode
|
||||
removable?: boolean
|
||||
block_delete?: boolean
|
||||
selected?: boolean
|
||||
showAdvanced?: boolean
|
||||
|
||||
get showAdvanced(): boolean | undefined {
|
||||
return this._showAdvanced
|
||||
}
|
||||
|
||||
set showAdvanced(value: boolean | undefined) {
|
||||
const oldValue = this._showAdvanced
|
||||
this._showAdvanced = value
|
||||
this.applyPresentationChange(oldValue, value, {
|
||||
showAdvanced: value
|
||||
})
|
||||
}
|
||||
|
||||
declare comfyDynamic?: Record<string, object>
|
||||
declare comfyClass?: string
|
||||
@@ -503,6 +638,33 @@ export class LGraphNode
|
||||
this.pos = [x, y]
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply position and size from the layout store without triggering
|
||||
* store mutations. Used exclusively by store→LiteGraph projection
|
||||
* (e.g. useLayoutSync) to avoid feedback loops.
|
||||
*/
|
||||
applyStoreProjection(
|
||||
pos: { x: number; y: number },
|
||||
size: { width: number; height: number }
|
||||
): boolean {
|
||||
let changed = false
|
||||
|
||||
if (this._pos[0] !== pos.x || this._pos[1] !== pos.y) {
|
||||
this._pos[0] = pos.x
|
||||
this._pos[1] = pos.y
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (this._size[0] !== size.width || this._size[1] !== size.height) {
|
||||
this._size[0] = size.width
|
||||
this._size[1] = size.height
|
||||
this.onResize?.(this._size)
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
public get size() {
|
||||
return this._size
|
||||
}
|
||||
@@ -553,14 +715,9 @@ export class LGraphNode
|
||||
default:
|
||||
this._shape = v
|
||||
}
|
||||
if (oldValue !== this._shape) {
|
||||
this.graph?.trigger('node:property:changed', {
|
||||
nodeId: this.id,
|
||||
property: 'shape',
|
||||
oldValue,
|
||||
newValue: this._shape
|
||||
})
|
||||
}
|
||||
this.applyPresentationChange(oldValue, this._shape, {
|
||||
shape: this._shape
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -807,12 +964,11 @@ export class LGraphNode
|
||||
this.type = type ?? ''
|
||||
this.size = [LiteGraph.NODE_WIDTH, 60]
|
||||
this.pos = [10, 10]
|
||||
this.initializeTrackedFlags()
|
||||
this.strokeStyles = {
|
||||
error: this._getErrorStrokeStyle,
|
||||
selected: this._getSelectedStrokeStyle
|
||||
}
|
||||
// Initialize property manager with tracked properties
|
||||
this.changeTracker = new LGraphNodeProperties(this)
|
||||
}
|
||||
|
||||
/** Internal callback for subgraph nodes. Do not implement externally. */
|
||||
@@ -916,9 +1072,6 @@ export class LGraphNode
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the state of this.resizable.
|
||||
if (this.pinned) this.resizable = false
|
||||
|
||||
if (this.widgets_up) {
|
||||
console.warn(
|
||||
`[LiteGraph] Node type "${this.type}" uses deprecated property "widgets_up". ` +
|
||||
@@ -930,6 +1083,40 @@ export class LGraphNode
|
||||
this.onConfigure?.(info)
|
||||
}
|
||||
|
||||
/**
|
||||
* Project serialized node data into layout and presentation stores.
|
||||
*
|
||||
* Store initialization is primarily handled by the node lifecycle bridge
|
||||
* (useGraphNodeManager.handleNodeAdded → initializeVueNodeLayout), which
|
||||
* fires on graph.add(). Property setters (title, mode, flags, etc.)
|
||||
* incrementally sync presentation state during configure() via
|
||||
* useNodeDisplayStore().updateNode(). This method provides an explicit
|
||||
* bulk-sync alternative for scenarios needing deterministic projection.
|
||||
*
|
||||
* @param info The serialized node data
|
||||
* @param nodeIndex Optional z-order index (array position during graph load)
|
||||
*/
|
||||
notifyStoresAfterConfigure(info: ISerialisedNode, nodeIndex?: number): void {
|
||||
const nodeId = String(this.id)
|
||||
const layout = extractLayoutFromSerialized(info, nodeIndex)
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
|
||||
const presentation = extractPresentationFromSerialized(info)
|
||||
const graphId = this.graph?.rootGraph?.id
|
||||
if (graphId) {
|
||||
useNodeDisplayStore().registerNode(graphId, nodeId, presentation)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* serialize the content
|
||||
*/
|
||||
@@ -1860,7 +2047,7 @@ export class LGraphNode
|
||||
canvasX: number,
|
||||
canvasY: number
|
||||
): CompassCorners | undefined {
|
||||
if (this.resizable === false) return
|
||||
if (!this.resizable) return
|
||||
|
||||
const { boundingRect } = this
|
||||
if (!boundingRect.containsXy(canvasX, canvasY)) return
|
||||
@@ -3553,7 +3740,6 @@ export class LGraphNode
|
||||
|
||||
this.graph._version++
|
||||
this.flags.pinned = v ?? !this.flags.pinned
|
||||
this.resizable = !this.pinned
|
||||
if (!this.pinned) this.flags.pinned = undefined
|
||||
}
|
||||
|
||||
@@ -3800,7 +3986,7 @@ export class LGraphNode
|
||||
|
||||
if (this.collapsed) {
|
||||
// For collapsed nodes, limit to 20 chars as before
|
||||
displayTitle = title.substr(0, 20)
|
||||
displayTitle = title.slice(0, 20)
|
||||
} else if (availableWidth > 0) {
|
||||
// For regular nodes, truncate based on available width
|
||||
displayTitle = truncateText(ctx, title, availableWidth)
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createMockLGraph,
|
||||
createMockLGraphNode
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
describe('LGraphNodeProperties', () => {
|
||||
let mockNode: LGraphNode
|
||||
let mockGraph: LGraph
|
||||
|
||||
beforeEach(() => {
|
||||
mockGraph = createMockLGraph()
|
||||
|
||||
mockNode = createMockLGraphNode({
|
||||
id: 123,
|
||||
title: 'Test Node',
|
||||
flags: {},
|
||||
graph: mockGraph
|
||||
})
|
||||
})
|
||||
|
||||
describe('property tracking', () => {
|
||||
it('should track changes to existing properties', () => {
|
||||
new LGraphNodeProperties(mockNode)
|
||||
|
||||
mockNode.title = 'New Title'
|
||||
|
||||
expect(mockGraph.trigger).toHaveBeenCalledWith('node:property:changed', {
|
||||
nodeId: mockNode.id,
|
||||
property: 'title',
|
||||
oldValue: 'Test Node',
|
||||
newValue: 'New Title'
|
||||
})
|
||||
})
|
||||
|
||||
it('should track changes to nested properties', () => {
|
||||
new LGraphNodeProperties(mockNode)
|
||||
|
||||
mockNode.flags.collapsed = true
|
||||
|
||||
expect(mockGraph.trigger).toHaveBeenCalledWith('node:property:changed', {
|
||||
nodeId: mockNode.id,
|
||||
property: 'flags.collapsed',
|
||||
oldValue: undefined,
|
||||
newValue: true
|
||||
})
|
||||
})
|
||||
|
||||
it('should emit event when value is set to the same value', () => {
|
||||
new LGraphNodeProperties(mockNode)
|
||||
|
||||
mockNode.title = 'Test Node' // Same value as original
|
||||
|
||||
expect(mockGraph.trigger).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not emit events when node has no graph', () => {
|
||||
mockNode.graph = null
|
||||
new LGraphNodeProperties(mockNode)
|
||||
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
mockNode.title = 'New Title'
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTracked', () => {
|
||||
it('should correctly identify tracked properties', () => {
|
||||
const propManager = new LGraphNodeProperties(mockNode)
|
||||
|
||||
expect(propManager.isTracked('title')).toBe(true)
|
||||
expect(propManager.isTracked('flags.collapsed')).toBe(true)
|
||||
expect(propManager.isTracked('untracked')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('serialization behavior', () => {
|
||||
it('should not make non-existent properties enumerable', () => {
|
||||
new LGraphNodeProperties(mockNode)
|
||||
|
||||
// flags.collapsed doesn't exist initially
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
mockNode.flags,
|
||||
'collapsed'
|
||||
)
|
||||
expect(descriptor?.enumerable).toBe(false)
|
||||
})
|
||||
|
||||
it('should make properties enumerable when set to non-default values', () => {
|
||||
new LGraphNodeProperties(mockNode)
|
||||
|
||||
mockNode.flags.collapsed = true
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
mockNode.flags,
|
||||
'collapsed'
|
||||
)
|
||||
expect(descriptor?.enumerable).toBe(true)
|
||||
})
|
||||
|
||||
it('should make properties non-enumerable when set back to undefined', () => {
|
||||
new LGraphNodeProperties(mockNode)
|
||||
|
||||
mockNode.flags.collapsed = true
|
||||
mockNode.flags.collapsed = undefined
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
mockNode.flags,
|
||||
'collapsed'
|
||||
)
|
||||
expect(descriptor?.enumerable).toBe(false)
|
||||
})
|
||||
|
||||
it('should keep existing properties enumerable', () => {
|
||||
// title exists initially
|
||||
const initialDescriptor = Object.getOwnPropertyDescriptor(
|
||||
mockNode,
|
||||
'title'
|
||||
)
|
||||
expect(initialDescriptor?.enumerable).toBe(true)
|
||||
|
||||
new LGraphNodeProperties(mockNode)
|
||||
|
||||
const afterDescriptor = Object.getOwnPropertyDescriptor(mockNode, 'title')
|
||||
expect(afterDescriptor?.enumerable).toBe(true)
|
||||
})
|
||||
|
||||
it('should only include non-undefined values in JSON.stringify', () => {
|
||||
new LGraphNodeProperties(mockNode)
|
||||
|
||||
// Initially, flags.collapsed shouldn't appear
|
||||
let json = JSON.parse(JSON.stringify(mockNode))
|
||||
expect(json.flags.collapsed).toBeUndefined()
|
||||
|
||||
// After setting to true, it should appear
|
||||
mockNode.flags.collapsed = true
|
||||
json = JSON.parse(JSON.stringify(mockNode))
|
||||
expect(json.flags.collapsed).toBe(true)
|
||||
|
||||
// After setting to false, it should still appear (false is not undefined)
|
||||
mockNode.flags.collapsed = false
|
||||
json = JSON.parse(JSON.stringify(mockNode))
|
||||
expect(json.flags.collapsed).toBe(false)
|
||||
|
||||
// After setting back to undefined, it should disappear
|
||||
mockNode.flags.collapsed = undefined
|
||||
json = JSON.parse(JSON.stringify(mockNode))
|
||||
expect(json.flags.collapsed).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,228 +0,0 @@
|
||||
import type { LGraphNode } from './LGraphNode'
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Default properties to track
|
||||
*/
|
||||
const DEFAULT_TRACKED_PROPERTIES: string[] = [
|
||||
'title',
|
||||
'flags.collapsed',
|
||||
'flags.pinned',
|
||||
'mode',
|
||||
'color',
|
||||
'bgcolor',
|
||||
'shape',
|
||||
'showAdvanced'
|
||||
]
|
||||
/**
|
||||
* Manages node properties with optional change tracking and instrumentation.
|
||||
*/
|
||||
export class LGraphNodeProperties {
|
||||
/** The node this property manager belongs to */
|
||||
node: LGraphNode
|
||||
|
||||
/** Set of property paths that have been instrumented */
|
||||
private _instrumentedPaths = new Set<string>()
|
||||
|
||||
constructor(node: LGraphNode) {
|
||||
this.node = node
|
||||
|
||||
this._setupInstrumentation()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up property instrumentation for all tracked properties
|
||||
*/
|
||||
private _setupInstrumentation(): void {
|
||||
for (const path of DEFAULT_TRACKED_PROPERTIES) {
|
||||
this._instrumentProperty(path)
|
||||
}
|
||||
}
|
||||
|
||||
private _resolveTargetObject(parts: string[]): {
|
||||
targetObject: Record<string, unknown>
|
||||
propertyName: string
|
||||
} {
|
||||
// LGraphNode supports dynamic property access at runtime
|
||||
let targetObject: Record<string, unknown> = this.node as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
if (parts.length === 1) {
|
||||
return { targetObject, propertyName: parts[0] }
|
||||
}
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const key = parts[i]
|
||||
const next = targetObject[key]
|
||||
if (isRecord(next)) {
|
||||
targetObject = next
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
targetObject,
|
||||
propertyName: parts[parts.length - 1]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instruments a single property to track changes
|
||||
*/
|
||||
private _instrumentProperty(path: string): void {
|
||||
const parts = path.split('.')
|
||||
|
||||
if (parts.length > 1) {
|
||||
this._ensureNestedPath(path)
|
||||
}
|
||||
|
||||
const { targetObject, propertyName } = this._resolveTargetObject(parts)
|
||||
|
||||
const hasProperty = Object.prototype.hasOwnProperty.call(
|
||||
targetObject,
|
||||
propertyName
|
||||
)
|
||||
const currentValue = targetObject[propertyName]
|
||||
|
||||
if (!hasProperty) {
|
||||
let value: unknown = undefined
|
||||
|
||||
Object.defineProperty(targetObject, propertyName, {
|
||||
get: () => value,
|
||||
set: (newValue: unknown) => {
|
||||
const oldValue = value
|
||||
value = newValue
|
||||
this._emitPropertyChange(path, oldValue, newValue)
|
||||
|
||||
// Update enumerable: true for non-undefined values, false for undefined
|
||||
const shouldBeEnumerable = newValue !== undefined
|
||||
const currentDescriptor = Object.getOwnPropertyDescriptor(
|
||||
targetObject,
|
||||
propertyName
|
||||
)
|
||||
if (
|
||||
currentDescriptor &&
|
||||
currentDescriptor.enumerable !== shouldBeEnumerable
|
||||
) {
|
||||
Object.defineProperty(targetObject, propertyName, {
|
||||
...currentDescriptor,
|
||||
enumerable: shouldBeEnumerable
|
||||
})
|
||||
}
|
||||
},
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
})
|
||||
} else {
|
||||
Object.defineProperty(
|
||||
targetObject,
|
||||
propertyName,
|
||||
this._createInstrumentedDescriptor(path, currentValue)
|
||||
)
|
||||
}
|
||||
|
||||
this._instrumentedPaths.add(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a property descriptor that emits change events
|
||||
*/
|
||||
private _createInstrumentedDescriptor(
|
||||
propertyPath: string,
|
||||
initialValue: unknown
|
||||
): PropertyDescriptor {
|
||||
return this._createInstrumentedDescriptorTyped(propertyPath, initialValue)
|
||||
}
|
||||
|
||||
private _createInstrumentedDescriptorTyped<TValue>(
|
||||
propertyPath: string,
|
||||
initialValue: TValue
|
||||
): PropertyDescriptor {
|
||||
let value: TValue = initialValue
|
||||
|
||||
return {
|
||||
get: () => value,
|
||||
set: (newValue: TValue) => {
|
||||
const oldValue = value
|
||||
value = newValue
|
||||
this._emitPropertyChange(propertyPath, oldValue, newValue)
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a property change event if the node is connected to a graph
|
||||
*/
|
||||
private _emitPropertyChange(
|
||||
propertyPath: string,
|
||||
oldValue: unknown,
|
||||
newValue: unknown
|
||||
): void {
|
||||
this._emitPropertyChangeTyped(propertyPath, oldValue, newValue)
|
||||
}
|
||||
|
||||
private _emitPropertyChangeTyped<TValue>(
|
||||
propertyPath: string,
|
||||
oldValue: TValue,
|
||||
newValue: TValue
|
||||
): void {
|
||||
this.node.graph?.trigger('node:property:changed', {
|
||||
nodeId: this.node.id,
|
||||
property: propertyPath,
|
||||
oldValue,
|
||||
newValue
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures parent objects exist for nested properties
|
||||
*/
|
||||
private _ensureNestedPath(path: string): void {
|
||||
const parts = path.split('.')
|
||||
// LGraphNode supports dynamic property access at runtime
|
||||
let current: Record<string, unknown> = this.node as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
// Create all parent objects except the last property
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i]
|
||||
if (!current[part]) {
|
||||
current[part] = {}
|
||||
}
|
||||
const next = current[part]
|
||||
if (isRecord(next)) {
|
||||
current = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a property is being tracked
|
||||
*/
|
||||
isTracked(path: string): boolean {
|
||||
return this._instrumentedPaths.has(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of tracked properties
|
||||
*/
|
||||
getTrackedProperties(): string[] {
|
||||
return [...DEFAULT_TRACKED_PROPERTIES]
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom toJSON method for JSON.stringify
|
||||
* Returns undefined to exclude from serialization since we only use defaults
|
||||
*/
|
||||
toJSON(): undefined {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
// oxlint-disable no-empty-pattern
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { test as baseTest } from 'vitest'
|
||||
|
||||
import { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
@@ -33,13 +35,22 @@ interface DirtyFixtures {
|
||||
basicSerialisableGraph: SerialisableGraph
|
||||
}
|
||||
|
||||
export const test = baseTest.extend<LitegraphFixtures>({
|
||||
const withPinia = baseTest.extend({
|
||||
// Auto-fixture: sets up Pinia for every test
|
||||
|
||||
_pinia: [
|
||||
async ({}, use) => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
await use(undefined)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
|
||||
export const test = withPinia.extend<LitegraphFixtures>({
|
||||
minimalGraph: async ({}, use) => {
|
||||
// Before each test function
|
||||
const serialisable = structuredClone(minimalSerialisableGraph)
|
||||
const lGraph = new LGraph(serialisable)
|
||||
|
||||
// use the fixture value
|
||||
await use(lGraph)
|
||||
},
|
||||
minimalSerialisableGraph: structuredClone(minimalSerialisableGraph),
|
||||
|
||||
@@ -107,8 +107,6 @@ export {
|
||||
LGraph,
|
||||
type GroupNodeConfigEntry,
|
||||
type GroupNodeWorkflowData,
|
||||
type LGraphTriggerAction,
|
||||
type LGraphTriggerParam,
|
||||
type GraphAddOptions
|
||||
} from './LGraph'
|
||||
export type { LGraphTriggerEvent } from './types/graphTriggers'
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import type { NodeId } from '../LGraphNode'
|
||||
import type { NodeSlotType } from './globalEnums'
|
||||
|
||||
interface NodePropertyChangedEvent {
|
||||
type: 'node:property:changed'
|
||||
nodeId: NodeId
|
||||
property: string
|
||||
oldValue: unknown
|
||||
newValue: unknown
|
||||
}
|
||||
|
||||
interface NodeSlotErrorsChangedEvent {
|
||||
type: 'node:slot-errors:changed'
|
||||
nodeId: NodeId
|
||||
@@ -24,7 +16,6 @@ interface NodeSlotLinksChangedEvent {
|
||||
}
|
||||
|
||||
export type LGraphTriggerEvent =
|
||||
| NodePropertyChangedEvent
|
||||
| NodeSlotErrorsChangedEvent
|
||||
| NodeSlotLinksChangedEvent
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
@@ -44,6 +46,7 @@ describe('ColorWidget', () => {
|
||||
vi.useFakeTimers()
|
||||
// Reset modules to get fresh globalColorInput state
|
||||
vi.resetModules()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
const litegraph = await import('@/lib/litegraph/src/litegraph')
|
||||
LGraphNode = litegraph.LGraphNode
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Raw } from 'vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
|
||||
import type { Point, Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import { useNodeSelectionStore } from '@/renderer/core/selection/store/nodeSelectionStore'
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
@@ -39,6 +40,9 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
const updateSelectedItems = () => {
|
||||
const items = Array.from(canvas.value?.selectedItems ?? [])
|
||||
selectedItems.value = items.map((item) => markRaw(item))
|
||||
|
||||
const selectionStore = useNodeSelectionStore()
|
||||
selectionStore.syncFromCanvas(canvas.value?.selectedItems ?? [])
|
||||
}
|
||||
|
||||
// Reactive scale percentage that syncs with app.canvas.ds.scale
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
/**
|
||||
* Layout Mutations - Simplified Direct Operations
|
||||
*
|
||||
* Provides a clean API for layout operations that are CRDT-ready.
|
||||
* Operations are synchronous and applied directly to the store.
|
||||
*/
|
||||
import log from 'loglevel'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
@@ -20,16 +14,13 @@ import type {
|
||||
const logger = log.getLogger('LayoutMutations')
|
||||
|
||||
interface LayoutMutations {
|
||||
// Single node operations (synchronous, CRDT-ready)
|
||||
moveNode(nodeId: NodeId, position: Point): void
|
||||
resizeNode(nodeId: NodeId, size: Size): void
|
||||
setNodeZIndex(nodeId: NodeId, zIndex: number): void
|
||||
|
||||
// Node lifecycle operations
|
||||
createNode(nodeId: NodeId, layout: Partial<NodeLayout>): void
|
||||
deleteNode(nodeId: NodeId): void
|
||||
|
||||
// Link operations
|
||||
createLink(
|
||||
linkId: LinkId,
|
||||
sourceNodeId: NodeId,
|
||||
@@ -39,7 +30,6 @@ interface LayoutMutations {
|
||||
): void
|
||||
deleteLink(linkId: LinkId): void
|
||||
|
||||
// Reroute operations
|
||||
createReroute(
|
||||
rerouteId: RerouteId,
|
||||
position: Point,
|
||||
@@ -53,35 +43,22 @@ interface LayoutMutations {
|
||||
previousPosition: Point
|
||||
): void
|
||||
|
||||
// Stacking operations
|
||||
bringNodeToFront(nodeId: NodeId): void
|
||||
sendNodeToBack(nodeId: NodeId): void
|
||||
|
||||
// Source tracking
|
||||
setSource(source: LayoutSource): void
|
||||
setActor(actor: string): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for accessing layout mutations with clean destructuring API
|
||||
*/
|
||||
export function useLayoutMutations(): LayoutMutations {
|
||||
/**
|
||||
* Set the current mutation source
|
||||
*/
|
||||
const setSource = (source: LayoutSource): void => {
|
||||
layoutStore.setSource(source)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current actor (for CRDT)
|
||||
*/
|
||||
const setActor = (actor: string): void => {
|
||||
layoutStore.setActor(actor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a node to a new position
|
||||
*/
|
||||
const moveNode = (nodeId: NodeId, position: Point): void => {
|
||||
const normalizedNodeId = String(nodeId)
|
||||
const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
|
||||
@@ -99,9 +76,6 @@ export function useLayoutMutations(): LayoutMutations {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a node
|
||||
*/
|
||||
const resizeNode = (nodeId: NodeId, size: Size): void => {
|
||||
const normalizedNodeId = String(nodeId)
|
||||
const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
|
||||
@@ -119,9 +93,6 @@ export function useLayoutMutations(): LayoutMutations {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set node z-index
|
||||
*/
|
||||
const setNodeZIndex = (nodeId: NodeId, zIndex: number): void => {
|
||||
const normalizedNodeId = String(nodeId)
|
||||
const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
|
||||
@@ -139,9 +110,6 @@ export function useLayoutMutations(): LayoutMutations {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new node
|
||||
*/
|
||||
const createNode = (nodeId: NodeId, layout: Partial<NodeLayout>): void => {
|
||||
const normalizedNodeId = String(nodeId)
|
||||
const fullLayout: NodeLayout = {
|
||||
@@ -169,9 +137,6 @@ export function useLayoutMutations(): LayoutMutations {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a node
|
||||
*/
|
||||
const deleteNode = (nodeId: NodeId): void => {
|
||||
const normalizedNodeId = String(nodeId)
|
||||
const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
|
||||
@@ -188,27 +153,16 @@ export function useLayoutMutations(): LayoutMutations {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring a node to the front (highest z-index)
|
||||
*/
|
||||
const bringNodeToFront = (nodeId: NodeId): void => {
|
||||
// Get all nodes to find the highest z-index
|
||||
const allNodes = layoutStore.getAllNodes().value
|
||||
let maxZIndex = 0
|
||||
|
||||
for (const [, layout] of allNodes) {
|
||||
if (layout.zIndex > maxZIndex) {
|
||||
maxZIndex = layout.zIndex
|
||||
}
|
||||
}
|
||||
|
||||
// Set this node's z-index to be one higher than the current max
|
||||
const maxZIndex = layoutStore.getMaxZIndex()
|
||||
setNodeZIndex(nodeId, maxZIndex + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new link
|
||||
*/
|
||||
const sendNodeToBack = (nodeId: NodeId): void => {
|
||||
const minZIndex = layoutStore.getMinZIndex()
|
||||
setNodeZIndex(nodeId, minZIndex - 1)
|
||||
}
|
||||
|
||||
const createLink = (
|
||||
linkId: LinkId,
|
||||
sourceNodeId: NodeId,
|
||||
@@ -239,9 +193,6 @@ export function useLayoutMutations(): LayoutMutations {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a link
|
||||
*/
|
||||
const deleteLink = (linkId: LinkId): void => {
|
||||
logger.debug('Deleting link:', linkId)
|
||||
layoutStore.applyOperation({
|
||||
@@ -254,9 +205,6 @@ export function useLayoutMutations(): LayoutMutations {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new reroute
|
||||
*/
|
||||
const createReroute = (
|
||||
rerouteId: RerouteId,
|
||||
position: Point,
|
||||
@@ -282,9 +230,6 @@ export function useLayoutMutations(): LayoutMutations {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a reroute
|
||||
*/
|
||||
const deleteReroute = (rerouteId: RerouteId): void => {
|
||||
logger.debug('Deleting reroute:', rerouteId)
|
||||
layoutStore.applyOperation({
|
||||
@@ -297,9 +242,6 @@ export function useLayoutMutations(): LayoutMutations {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a reroute
|
||||
*/
|
||||
const moveReroute = (
|
||||
rerouteId: RerouteId,
|
||||
position: Point,
|
||||
@@ -331,6 +273,7 @@ export function useLayoutMutations(): LayoutMutations {
|
||||
createNode,
|
||||
deleteNode,
|
||||
bringNodeToFront,
|
||||
sendNodeToBack,
|
||||
createLink,
|
||||
deleteLink,
|
||||
createReroute,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { NodeId, NodeLayout } from '@/renderer/core/layout/types'
|
||||
import type { NodeDisplayState } from '@/stores/nodeDisplayStore'
|
||||
|
||||
export function extractLayoutFromSerialized(
|
||||
node: ISerialisedNode,
|
||||
zIndex = 0
|
||||
): NodeLayout {
|
||||
const id: NodeId = String(node.id)
|
||||
const x = node.pos[0]
|
||||
const y = node.pos[1]
|
||||
const width = node.size[0]
|
||||
const height = node.size[1]
|
||||
|
||||
return {
|
||||
id,
|
||||
position: { x, y },
|
||||
size: { width, height },
|
||||
zIndex,
|
||||
visible: true,
|
||||
bounds: { x, y, width, height }
|
||||
}
|
||||
}
|
||||
|
||||
export function extractPresentationFromSerialized(
|
||||
node: ISerialisedNode
|
||||
): NodeDisplayState {
|
||||
return {
|
||||
id: String(node.id),
|
||||
title: node.title ?? '',
|
||||
mode: node.mode,
|
||||
shape: node.shape,
|
||||
showAdvanced: node.showAdvanced,
|
||||
color: node.color,
|
||||
bgcolor: node.bgcolor,
|
||||
flags: {
|
||||
collapsed: node.flags?.collapsed,
|
||||
pinned: node.flags?.pinned,
|
||||
ghost: node.flags?.ghost
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
@@ -457,3 +458,78 @@ describe('layoutStore CRDT operations', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('z-order operations', () => {
|
||||
beforeEach(() => {
|
||||
layoutStore.initializeFromLiteGraph([])
|
||||
})
|
||||
|
||||
const createNodeWithZIndex = (id: string, zIndex: number) => {
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId: id,
|
||||
layout: {
|
||||
id,
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 200, height: 100 },
|
||||
zIndex,
|
||||
visible: true,
|
||||
bounds: { x: 0, y: 0, width: 200, height: 100 }
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
}
|
||||
|
||||
it('bringNodeToFront sets highest zIndex', () => {
|
||||
createNodeWithZIndex('node-0', 0)
|
||||
createNodeWithZIndex('node-1', 1)
|
||||
createNodeWithZIndex('node-2', 2)
|
||||
|
||||
const { bringNodeToFront } = useLayoutMutations()
|
||||
bringNodeToFront('node-0')
|
||||
|
||||
const node0 = layoutStore.getNodeLayoutRef('node-0').value
|
||||
expect(node0!.zIndex).toBeGreaterThan(2)
|
||||
})
|
||||
|
||||
it('sendNodeToBack sets lowest zIndex', () => {
|
||||
createNodeWithZIndex('node-0', 0)
|
||||
createNodeWithZIndex('node-1', 1)
|
||||
createNodeWithZIndex('node-2', 2)
|
||||
|
||||
const { sendNodeToBack } = useLayoutMutations()
|
||||
sendNodeToBack('node-2')
|
||||
|
||||
const node2 = layoutStore.getNodeLayoutRef('node-2').value
|
||||
expect(node2!.zIndex).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('bringNodeToFront is idempotent for topmost node', () => {
|
||||
createNodeWithZIndex('node-0', 0)
|
||||
createNodeWithZIndex('node-1', 1)
|
||||
createNodeWithZIndex('node-2', 2)
|
||||
|
||||
const { bringNodeToFront } = useLayoutMutations()
|
||||
bringNodeToFront('node-2')
|
||||
|
||||
const node2 = layoutStore.getNodeLayoutRef('node-2').value
|
||||
expect(node2!.zIndex).toBe(3)
|
||||
})
|
||||
|
||||
it('z-order queries return correct max/min', () => {
|
||||
createNodeWithZIndex('node-a', -5)
|
||||
createNodeWithZIndex('node-b', 3)
|
||||
createNodeWithZIndex('node-c', 10)
|
||||
|
||||
expect(layoutStore.getMaxZIndex()).toBe(10)
|
||||
expect(layoutStore.getMinZIndex()).toBe(-5)
|
||||
})
|
||||
|
||||
it('z-order queries return 0 when no nodes exist', () => {
|
||||
expect(layoutStore.getMaxZIndex()).toBe(0)
|
||||
expect(layoutStore.getMinZIndex()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -377,6 +377,32 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
return computed(() => this.version)
|
||||
}
|
||||
|
||||
getMaxZIndex(): number {
|
||||
let max = 0
|
||||
for (const [, ynode] of this.ynodes) {
|
||||
if (ynode) {
|
||||
const layout = yNodeToLayout(ynode)
|
||||
if (layout && layout.zIndex > max) {
|
||||
max = layout.zIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
getMinZIndex(): number {
|
||||
let min = 0
|
||||
for (const [, ynode] of this.ynodes) {
|
||||
if (ynode) {
|
||||
const layout = yNodeToLayout(ynode)
|
||||
if (layout) {
|
||||
min = Math.min(min, layout.zIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
return min
|
||||
}
|
||||
|
||||
/**
|
||||
* Query node at point (non-reactive for performance)
|
||||
*/
|
||||
|
||||
@@ -1,65 +1,48 @@
|
||||
/**
|
||||
* 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 { onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
/**
|
||||
* Composable for syncing LiteGraph with the Layout system
|
||||
* This replaces the bidirectional sync with a one-way sync
|
||||
*/
|
||||
export function useLayoutSync() {
|
||||
const unsubscribe = ref<() => void>()
|
||||
|
||||
/**
|
||||
* Start syncing from Layout → LiteGraph
|
||||
*/
|
||||
function startSync(canvas: ReturnType<typeof useCanvasStore>['canvas']) {
|
||||
if (!canvas?.graph) return
|
||||
|
||||
// Cancel last subscription
|
||||
stopSync()
|
||||
// Subscribe to layout changes
|
||||
unsubscribe.value = layoutStore.onChange((change) => {
|
||||
// Apply changes to LiteGraph regardless of source
|
||||
// The layout store is the single source of truth
|
||||
const graph = canvas.graph
|
||||
if (!graph) return
|
||||
|
||||
// Apply changes to LiteGraph regardless of source.
|
||||
// The layout store is the single source of truth;
|
||||
// this is a one-way projection: store → LiteGraph.
|
||||
for (const nodeId of change.nodeIds) {
|
||||
const layout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (!layout) continue
|
||||
|
||||
const liteNode = canvas.graph?.getNodeById(parseInt(nodeId))
|
||||
const liteNode = graph.getNodeById(parseInt(nodeId))
|
||||
if (!liteNode) continue
|
||||
|
||||
if (
|
||||
liteNode.pos[0] !== layout.position.x ||
|
||||
liteNode.pos[1] !== layout.position.y
|
||||
) {
|
||||
liteNode.pos[0] = layout.position.x
|
||||
liteNode.pos[1] = layout.position.y
|
||||
}
|
||||
|
||||
// Note: layout.size.height is the content height without title.
|
||||
// LiteGraph's measure() will add titleHeight to get boundingRect.
|
||||
// Do NOT use addNodeTitleHeight here - that would double-count the title.
|
||||
if (
|
||||
liteNode.size[0] !== layout.size.width ||
|
||||
liteNode.size[1] !== layout.size.height
|
||||
) {
|
||||
// Update internal size directly (like position above) to avoid
|
||||
// the size setter writing back to layoutStore with Canvas source,
|
||||
// which would create a feedback loop through handleLayoutChange.
|
||||
liteNode.size[0] = layout.size.width
|
||||
liteNode.size[1] = layout.size.height
|
||||
liteNode.onResize?.(liteNode.size)
|
||||
}
|
||||
// Use applyStoreProjection to write directly to backing arrays
|
||||
// without triggering pos/size setters (which would write back
|
||||
// to the store and create a feedback loop).
|
||||
liteNode.applyStoreProjection(layout.position, layout.size)
|
||||
}
|
||||
|
||||
// Sync render order when z-index changes
|
||||
if (change.operation.type === 'setNodeZIndex') {
|
||||
const zIndexMap = new Map(
|
||||
graph._nodes.map((n) => [
|
||||
n.id,
|
||||
layoutStore.getNodeLayoutRef(String(n.id)).value?.zIndex ?? 0
|
||||
])
|
||||
)
|
||||
graph._nodes.sort(
|
||||
(a, b) => (zIndexMap.get(a.id) ?? 0) - (zIndexMap.get(b.id) ?? 0)
|
||||
)
|
||||
}
|
||||
|
||||
// Trigger single redraw for all changes
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
}
|
||||
|
||||
81
src/renderer/core/selection/store/nodeSelectionStore.test.ts
Normal file
81
src/renderer/core/selection/store/nodeSelectionStore.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
import { useNodeSelectionStore } from './nodeSelectionStore'
|
||||
|
||||
function mockPositionable(id?: number): Positionable {
|
||||
return { id } as unknown as Positionable
|
||||
}
|
||||
|
||||
describe('useNodeSelectionStore', () => {
|
||||
let store: ReturnType<typeof useNodeSelectionStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useNodeSelectionStore()
|
||||
})
|
||||
|
||||
it('initializes with empty selection', () => {
|
||||
expect(store.hasSelection).toBe(false)
|
||||
expect(store.selectionCount).toBe(0)
|
||||
expect(store.selectedNodeIds.size).toBe(0)
|
||||
})
|
||||
|
||||
it('syncFromCanvas updates selection from items', () => {
|
||||
const items = [mockPositionable(1), mockPositionable(2)]
|
||||
store.syncFromCanvas(items)
|
||||
|
||||
expect(store.selectionCount).toBe(2)
|
||||
expect(store.hasSelection).toBe(true)
|
||||
expect(store.selectedNodeIds).toEqual(new Set(['1', '2']))
|
||||
})
|
||||
|
||||
it('syncFromCanvas skips items without id', () => {
|
||||
const items = [
|
||||
mockPositionable(1),
|
||||
mockPositionable(undefined),
|
||||
mockPositionable(3)
|
||||
]
|
||||
store.syncFromCanvas(items)
|
||||
|
||||
expect(store.selectionCount).toBe(2)
|
||||
expect(store.selectedNodeIds).toEqual(new Set(['1', '3']))
|
||||
})
|
||||
|
||||
it('syncFromCanvas no-ops when selection unchanged', () => {
|
||||
const items = [mockPositionable(1), mockPositionable(2)]
|
||||
store.syncFromCanvas(items)
|
||||
const idsAfterFirst = store.selectedNodeIds
|
||||
|
||||
store.syncFromCanvas(items)
|
||||
expect(store.selectedNodeIds.size).toBe(idsAfterFirst.size)
|
||||
expect([...store.selectedNodeIds]).toEqual([...idsAfterFirst])
|
||||
})
|
||||
|
||||
it('clear empties selection', () => {
|
||||
store.syncFromCanvas([mockPositionable(1), mockPositionable(2)])
|
||||
expect(store.hasSelection).toBe(true)
|
||||
|
||||
store.clear()
|
||||
expect(store.hasSelection).toBe(false)
|
||||
expect(store.selectionCount).toBe(0)
|
||||
})
|
||||
|
||||
it('clear no-ops when already empty', () => {
|
||||
store.clear()
|
||||
expect(store.hasSelection).toBe(false)
|
||||
expect(store.selectionCount).toBe(0)
|
||||
})
|
||||
|
||||
it('isSelected returns correct boolean', () => {
|
||||
store.syncFromCanvas([mockPositionable(1), mockPositionable(3)])
|
||||
|
||||
expect(store.isSelected('1')).toBe(true)
|
||||
expect(store.isSelected('3')).toBe(true)
|
||||
expect(store.isSelected('2')).toBe(false)
|
||||
expect(store.isSelected('999')).toBe(false)
|
||||
})
|
||||
})
|
||||
49
src/renderer/core/selection/store/nodeSelectionStore.ts
Normal file
49
src/renderer/core/selection/store/nodeSelectionStore.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
export const useNodeSelectionStore = defineStore('nodeSelection', () => {
|
||||
const selectedItemIds = ref<Set<string>>(new Set())
|
||||
|
||||
function syncFromCanvas(items: Iterable<Positionable>): void {
|
||||
const newIds = new Set<string>()
|
||||
for (const item of items) {
|
||||
if (item.id !== undefined) {
|
||||
newIds.add(String(item.id))
|
||||
}
|
||||
}
|
||||
|
||||
const current = selectedItemIds.value
|
||||
if (
|
||||
newIds.size !== current.size ||
|
||||
![...newIds].every((id) => current.has(id))
|
||||
) {
|
||||
selectedItemIds.value = newIds
|
||||
}
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
if (selectedItemIds.value.size > 0) {
|
||||
selectedItemIds.value = new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function isSelected(nodeId: string): boolean {
|
||||
return selectedItemIds.value.has(nodeId)
|
||||
}
|
||||
|
||||
const selectedNodeIds = computed(() => selectedItemIds.value)
|
||||
const selectionCount = computed(() => selectedItemIds.value.size)
|
||||
const hasSelection = computed(() => selectedItemIds.value.size > 0)
|
||||
|
||||
return {
|
||||
selectedItemIds,
|
||||
selectedNodeIds,
|
||||
selectionCount,
|
||||
hasSelection,
|
||||
syncFromCanvas,
|
||||
clear,
|
||||
isSelected
|
||||
}
|
||||
})
|
||||
@@ -2,13 +2,10 @@ import { useThrottleFn } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LGraphTriggerEvent
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useNodeDisplayStore } from '@/stores/nodeDisplayStore'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import { MinimapDataSourceFactory } from '../data/MinimapDataSourceFactory'
|
||||
@@ -18,7 +15,6 @@ interface GraphCallbacks {
|
||||
onNodeAdded?: (node: LGraphNode) => void
|
||||
onNodeRemoved?: (node: LGraphNode) => void
|
||||
onConnectionChange?: (node: LGraphNode) => void
|
||||
onTrigger?: (event: LGraphTriggerEvent) => void
|
||||
}
|
||||
|
||||
export function useMinimapGraph(
|
||||
@@ -40,6 +36,7 @@ export function useMinimapGraph(
|
||||
|
||||
// Map to store original callbacks per graph ID
|
||||
const originalCallbacksMap = new Map<string, GraphCallbacks>()
|
||||
let stopPresentationSync: (() => void) | undefined
|
||||
|
||||
const handleGraphChangedThrottled = useThrottleFn(() => {
|
||||
onGraphChanged()
|
||||
@@ -58,8 +55,7 @@ export function useMinimapGraph(
|
||||
const originalCallbacks: GraphCallbacks = {
|
||||
onNodeAdded: g.onNodeAdded,
|
||||
onNodeRemoved: g.onNodeRemoved,
|
||||
onConnectionChange: g.onConnectionChange,
|
||||
onTrigger: g.onTrigger
|
||||
onConnectionChange: g.onConnectionChange
|
||||
}
|
||||
originalCallbacksMap.set(g.id, originalCallbacks)
|
||||
|
||||
@@ -78,22 +74,6 @@ export function useMinimapGraph(
|
||||
originalCallbacks.onConnectionChange?.call(this, node)
|
||||
void handleGraphChangedThrottled()
|
||||
}
|
||||
|
||||
g.onTrigger = function (event: LGraphTriggerEvent) {
|
||||
originalCallbacks.onTrigger?.call(this, event)
|
||||
|
||||
// Listen for visual property changes that affect minimap rendering
|
||||
if (
|
||||
event.type === 'node:property:changed' &&
|
||||
(event.property === 'mode' ||
|
||||
event.property === 'bgcolor' ||
|
||||
event.property === 'color')
|
||||
) {
|
||||
// Invalidate cache for this node to force redraw
|
||||
nodeStatesCache.delete(String(event.nodeId))
|
||||
void handleGraphChangedThrottled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupEventListeners = (oldGraph?: LGraph) => {
|
||||
@@ -109,7 +89,6 @@ export function useMinimapGraph(
|
||||
g.onNodeAdded = originalCallbacks.onNodeAdded
|
||||
g.onNodeRemoved = originalCallbacks.onNodeRemoved
|
||||
g.onConnectionChange = originalCallbacks.onConnectionChange
|
||||
g.onTrigger = originalCallbacks.onTrigger
|
||||
|
||||
originalCallbacksMap.delete(g.id)
|
||||
}
|
||||
@@ -174,6 +153,21 @@ export function useMinimapGraph(
|
||||
|
||||
const init = () => {
|
||||
setupEventListeners()
|
||||
|
||||
const currentGraph = graph.value
|
||||
const graphId = currentGraph?.rootGraph?.id
|
||||
if (graphId) {
|
||||
const nodeDisplayStore = useNodeDisplayStore()
|
||||
const displayMap = nodeDisplayStore.getDisplayMap(graphId)
|
||||
stopPresentationSync = watch(
|
||||
displayMap,
|
||||
() => {
|
||||
void handleGraphChangedThrottled()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
}
|
||||
|
||||
api.addEventListener('graphChanged', handleGraphChangedThrottled)
|
||||
|
||||
watch(layoutStoreVersion, () => {
|
||||
@@ -182,6 +176,9 @@ export function useMinimapGraph(
|
||||
}
|
||||
|
||||
const destroy = () => {
|
||||
stopPresentationSync?.()
|
||||
stopPresentationSync = undefined
|
||||
|
||||
cleanupEventListeners()
|
||||
api.removeEventListener('graphChanged', handleGraphChangedThrottled)
|
||||
nodeStatesCache.clear()
|
||||
|
||||
@@ -21,12 +21,14 @@ export class LayoutStoreDataSource extends AbstractMinimapDataSource {
|
||||
|
||||
const nodes: MinimapNodeData[] = []
|
||||
|
||||
const zIndexMap = new Map<string, number>()
|
||||
|
||||
for (const [nodeId, layout] of allNodes) {
|
||||
// Find corresponding LiteGraph node for additional properties
|
||||
const graphNode = this.graph?._nodes?.find((n) => String(n.id) === nodeId)
|
||||
|
||||
const executionState = nodeProgressStates[nodeId]?.state ?? null
|
||||
|
||||
zIndexMap.set(nodeId, layout.zIndex)
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
x: layout.position.x,
|
||||
@@ -40,6 +42,11 @@ export class LayoutStoreDataSource extends AbstractMinimapDataSource {
|
||||
})
|
||||
}
|
||||
|
||||
nodes.sort(
|
||||
(a, b) =>
|
||||
(zIndexMap.get(String(a.id)) ?? 0) - (zIndexMap.get(String(b.id)) ?? 0)
|
||||
)
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
|
||||
@@ -69,8 +69,9 @@ export function ensureCorrectLayoutScale(
|
||||
|
||||
const scaledHeight = lgNode.size[1] * scaleFactor
|
||||
|
||||
// Directly update LiteGraph node to ensure immediate consistency
|
||||
// Dont need to reference vue directly because the pos and dims are already in yjs
|
||||
// Direct array element writes here are intentional: the layout store
|
||||
// is batch-updated below (batchUpdateNodeBounds), so using setters
|
||||
// would cause redundant individual store mutations.
|
||||
lgNode.pos[0] = scaledX
|
||||
lgNode.pos[1] = scaledY
|
||||
lgNode.size[0] = scaledWidth
|
||||
|
||||
235
src/scripts/changeTracker.storeContract.test.ts
Normal file
235
src/scripts/changeTracker.storeContract.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { ChangeTracker } from './changeTracker'
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
graph: { _nodes: [] } as Record<string, unknown>,
|
||||
rootGraph: { serialize: vi.fn() } as Record<string, unknown>,
|
||||
canvas: { ds: { scale: 1, offset: [0, 0] } },
|
||||
loadGraphData: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: mockApp
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
dispatchCustomEvent: vi.fn(),
|
||||
addEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
getNodeLayoutRef: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
ComfyWorkflow: class {},
|
||||
useWorkflowStore: () => ({
|
||||
getWorkflowByPath: vi.fn(),
|
||||
activeWorkflow: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
snapshotOutputs: vi.fn(() => ({})),
|
||||
restoreOutputs: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/subgraphNavigationStore', () => ({
|
||||
useSubgraphNavigationStore: () => ({
|
||||
exportState: vi.fn(() => []),
|
||||
restoreState: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => ({
|
||||
queuedJobs: {}
|
||||
})
|
||||
}))
|
||||
|
||||
function makeNode(
|
||||
id: number,
|
||||
overrides: Record<string, unknown> = {}
|
||||
): ComfyWorkflowJSON['nodes'][number] {
|
||||
return {
|
||||
id,
|
||||
type: 'TestNode',
|
||||
pos: [100, 200],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeWorkflow(
|
||||
overrides: Partial<ComfyWorkflowJSON> = {}
|
||||
): ComfyWorkflowJSON {
|
||||
return {
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [makeNode(1)],
|
||||
links: [],
|
||||
groups: [],
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
...overrides
|
||||
} as ComfyWorkflowJSON
|
||||
}
|
||||
|
||||
describe('ChangeTracker store contract', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('graphEqual', () => {
|
||||
it('returns true for identical snapshots', () => {
|
||||
const workflow = makeWorkflow()
|
||||
expect(ChangeTracker.graphEqual(workflow, workflow)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for equivalent snapshots with same data', () => {
|
||||
const a = makeWorkflow()
|
||||
const b = makeWorkflow()
|
||||
expect(ChangeTracker.graphEqual(a, b)).toBe(true)
|
||||
})
|
||||
|
||||
it('excludes viewport (ds) from comparison', () => {
|
||||
const a = makeWorkflow({
|
||||
extra: { ds: { scale: 1, offset: [0, 0] } }
|
||||
})
|
||||
const b = makeWorkflow({
|
||||
extra: { ds: { scale: 2.5, offset: [500, -300] } }
|
||||
})
|
||||
expect(ChangeTracker.graphEqual(a, b)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects changes in extra properties other than ds', () => {
|
||||
const a = makeWorkflow({
|
||||
extra: { ds: { scale: 1, offset: [0, 0] }, foo: 'bar' }
|
||||
})
|
||||
const b = makeWorkflow({
|
||||
extra: { ds: { scale: 1, offset: [0, 0] }, foo: 'baz' }
|
||||
})
|
||||
expect(ChangeTracker.graphEqual(a, b)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects node position changes', () => {
|
||||
const a = makeWorkflow({ nodes: [makeNode(1, { pos: [100, 200] })] })
|
||||
const b = makeWorkflow({ nodes: [makeNode(1, { pos: [300, 400] })] })
|
||||
expect(ChangeTracker.graphEqual(a, b)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects node size changes', () => {
|
||||
const a = makeWorkflow({ nodes: [makeNode(1, { size: [200, 100] })] })
|
||||
const b = makeWorkflow({ nodes: [makeNode(1, { size: [400, 200] })] })
|
||||
expect(ChangeTracker.graphEqual(a, b)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects node flag changes', () => {
|
||||
const a = makeWorkflow({ nodes: [makeNode(1, { flags: {} })] })
|
||||
const b = makeWorkflow({
|
||||
nodes: [makeNode(1, { flags: { collapsed: true } })]
|
||||
})
|
||||
expect(ChangeTracker.graphEqual(a, b)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects link changes', () => {
|
||||
const a = makeWorkflow({ links: [] })
|
||||
const b = makeWorkflow({
|
||||
links: [[1, 1, 0, 2, 0, 'MODEL']]
|
||||
})
|
||||
expect(ChangeTracker.graphEqual(a, b)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects group changes', () => {
|
||||
const a = makeWorkflow({ groups: [] })
|
||||
const b = makeWorkflow({
|
||||
groups: [{ title: 'Group 1', bounding: [0, 0, 100, 100] }]
|
||||
})
|
||||
expect(ChangeTracker.graphEqual(a, b)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats node order as irrelevant', () => {
|
||||
const a = makeWorkflow({
|
||||
nodes: [makeNode(1, { pos: [0, 0] }), makeNode(2, { pos: [100, 100] })]
|
||||
})
|
||||
const b = makeWorkflow({
|
||||
nodes: [makeNode(2, { pos: [100, 100] }), makeNode(1, { pos: [0, 0] })]
|
||||
})
|
||||
expect(ChangeTracker.graphEqual(a, b)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkState', () => {
|
||||
it('pushes to undoQueue when graph state changes', () => {
|
||||
const initialState = makeWorkflow()
|
||||
const changedState = makeWorkflow({
|
||||
nodes: [makeNode(1, { pos: [999, 999] })]
|
||||
})
|
||||
|
||||
mockApp.graph = { _nodes: [] }
|
||||
mockApp.rootGraph = { serialize: vi.fn(() => changedState) }
|
||||
|
||||
const tracker = new ChangeTracker(
|
||||
{} as never,
|
||||
JSON.parse(JSON.stringify(initialState))
|
||||
)
|
||||
|
||||
tracker.checkState()
|
||||
|
||||
expect(tracker.undoQueue).toHaveLength(1)
|
||||
expect(tracker.activeState).toEqual(changedState)
|
||||
expect(tracker.redoQueue).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not push to undoQueue when graph state is unchanged', () => {
|
||||
const state = makeWorkflow()
|
||||
|
||||
mockApp.graph = { _nodes: [] }
|
||||
mockApp.rootGraph = {
|
||||
serialize: vi.fn(() => JSON.parse(JSON.stringify(state)))
|
||||
}
|
||||
|
||||
const tracker = new ChangeTracker(
|
||||
{} as never,
|
||||
JSON.parse(JSON.stringify(state))
|
||||
)
|
||||
|
||||
tracker.checkState()
|
||||
|
||||
expect(tracker.undoQueue).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('clears redoQueue when a new change is detected', () => {
|
||||
const initialState = makeWorkflow()
|
||||
const changedState = makeWorkflow({
|
||||
nodes: [makeNode(1, { pos: [999, 999] })]
|
||||
})
|
||||
|
||||
mockApp.graph = { _nodes: [] }
|
||||
mockApp.rootGraph = { serialize: vi.fn(() => changedState) }
|
||||
|
||||
const tracker = new ChangeTracker(
|
||||
{} as never,
|
||||
JSON.parse(JSON.stringify(initialState))
|
||||
)
|
||||
tracker.redoQueue.push(makeWorkflow())
|
||||
|
||||
tracker.checkState()
|
||||
|
||||
expect(tracker.redoQueue).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
@@ -26,6 +27,27 @@ const logger = log.getLogger('ChangeTracker')
|
||||
// Change to debug for more verbose logging
|
||||
logger.setLevel('info')
|
||||
|
||||
function verifyStoreConsistency(): void {
|
||||
if (!app.graph) return
|
||||
|
||||
for (const node of app.graph._nodes) {
|
||||
const storeLayout = layoutStore.getNodeLayoutRef(String(node.id)).value
|
||||
if (!storeLayout) continue
|
||||
|
||||
const posMatch =
|
||||
Math.abs(storeLayout.position.x - node.pos[0]) < 0.01 &&
|
||||
Math.abs(storeLayout.position.y - node.pos[1]) < 0.01
|
||||
|
||||
if (!posMatch) {
|
||||
logger.warn(
|
||||
'Store/graph position mismatch after restore for node',
|
||||
node.id
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ChangeTracker {
|
||||
static MAX_HISTORY = 50
|
||||
/**
|
||||
@@ -171,6 +193,9 @@ export class ChangeTracker {
|
||||
})
|
||||
this.activeState = prevState
|
||||
this.updateModified()
|
||||
if (import.meta.env.DEV) {
|
||||
verifyStoreConsistency()
|
||||
}
|
||||
} finally {
|
||||
this._restoringState = false
|
||||
}
|
||||
|
||||
137
src/stores/nodeDisplayStore.test.ts
Normal file
137
src/stores/nodeDisplayStore.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { nextTick, watch } from 'vue'
|
||||
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
import { useNodeDisplayStore } from '@/stores/nodeDisplayStore'
|
||||
import type { NodeDisplayState } from '@/stores/nodeDisplayStore'
|
||||
|
||||
const GRAPH_ID = 'test-graph-id' as UUID
|
||||
const GRAPH_ID_2 = 'test-graph-id-2' as UUID
|
||||
|
||||
function createTestState(
|
||||
id: string,
|
||||
overrides?: Partial<NodeDisplayState>
|
||||
): NodeDisplayState {
|
||||
return {
|
||||
id,
|
||||
title: `Node ${id}`,
|
||||
mode: 0,
|
||||
flags: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useNodeDisplayStore', () => {
|
||||
let store: ReturnType<typeof useNodeDisplayStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useNodeDisplayStore()
|
||||
})
|
||||
|
||||
it('registerNode creates state retrievable by getNode', () => {
|
||||
const state = createTestState('n1')
|
||||
store.registerNode(GRAPH_ID, 'n1', state)
|
||||
|
||||
const result = store.getNode(GRAPH_ID, 'n1')
|
||||
expect(result).toMatchObject({
|
||||
id: 'n1',
|
||||
title: 'Node n1',
|
||||
mode: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('updateNode merges partial updates', () => {
|
||||
store.registerNode(GRAPH_ID, 'n2', createTestState('n2'))
|
||||
|
||||
store.updateNode(GRAPH_ID, 'n2', { title: 'Renamed', mode: 2 })
|
||||
|
||||
expect(store.getNode(GRAPH_ID, 'n2')?.title).toBe('Renamed')
|
||||
expect(store.getNode(GRAPH_ID, 'n2')?.mode).toBe(2)
|
||||
})
|
||||
|
||||
it('updateNode deduplicates when value is unchanged', () => {
|
||||
store.registerNode(GRAPH_ID, 'n3', createTestState('n3'))
|
||||
|
||||
const before = store.getNode(GRAPH_ID, 'n3')
|
||||
store.updateNode(GRAPH_ID, 'n3', { title: 'Node n3', mode: 0 })
|
||||
const after = store.getNode(GRAPH_ID, 'n3')
|
||||
|
||||
expect(before).toBe(after)
|
||||
})
|
||||
|
||||
it('updateNode merges flags shallowly', () => {
|
||||
store.registerNode(
|
||||
GRAPH_ID,
|
||||
'n4',
|
||||
createTestState('n4', { flags: { collapsed: true, pinned: false } })
|
||||
)
|
||||
|
||||
store.updateNode(GRAPH_ID, 'n4', { flags: { pinned: true } })
|
||||
|
||||
const node = store.getNode(GRAPH_ID, 'n4')
|
||||
expect(node?.flags.collapsed).toBe(true)
|
||||
expect(node?.flags.pinned).toBe(true)
|
||||
})
|
||||
|
||||
it('updateNode on non-existent node is a no-op', () => {
|
||||
store.updateNode(GRAPH_ID, 'ghost', { title: 'nope' })
|
||||
|
||||
expect(store.getNode(GRAPH_ID, 'ghost')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removeNode deletes state', () => {
|
||||
store.registerNode(GRAPH_ID, 'n5', createTestState('n5'))
|
||||
store.removeNode(GRAPH_ID, 'n5')
|
||||
|
||||
expect(store.getNode(GRAPH_ID, 'n5')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getNode returns undefined for non-existent nodes', () => {
|
||||
expect(store.getNode(GRAPH_ID, 'nonexistent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clearGraph removes all nodes for that graph only', () => {
|
||||
store.registerNode(GRAPH_ID, 'a', createTestState('a'))
|
||||
store.registerNode(GRAPH_ID, 'b', createTestState('b'))
|
||||
store.registerNode(GRAPH_ID_2, 'c', createTestState('c'))
|
||||
|
||||
store.clearGraph(GRAPH_ID)
|
||||
|
||||
expect(store.getNode(GRAPH_ID, 'a')).toBeUndefined()
|
||||
expect(store.getNode(GRAPH_ID, 'b')).toBeUndefined()
|
||||
expect(store.getNode(GRAPH_ID_2, 'c')).toBeDefined()
|
||||
})
|
||||
|
||||
it('getDisplayMap returns a reactive map', async () => {
|
||||
const displayMap = store.getDisplayMap(GRAPH_ID)
|
||||
|
||||
const onChange = vi.fn()
|
||||
watch(displayMap, onChange, { deep: true })
|
||||
|
||||
store.registerNode(GRAPH_ID, 'r1', createTestState('r1'))
|
||||
await nextTick()
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('graph isolation: nodes in different graphs do not interfere', () => {
|
||||
store.registerNode(GRAPH_ID, 'n1', createTestState('n1'))
|
||||
store.registerNode(
|
||||
GRAPH_ID_2,
|
||||
'n1',
|
||||
createTestState('n1', { title: 'Other' })
|
||||
)
|
||||
|
||||
expect(store.getNode(GRAPH_ID, 'n1')?.title).toBe('Node n1')
|
||||
expect(store.getNode(GRAPH_ID_2, 'n1')?.title).toBe('Other')
|
||||
|
||||
store.removeNode(GRAPH_ID, 'n1')
|
||||
|
||||
expect(store.getNode(GRAPH_ID, 'n1')).toBeUndefined()
|
||||
expect(store.getNode(GRAPH_ID_2, 'n1')?.title).toBe('Other')
|
||||
})
|
||||
})
|
||||
110
src/stores/nodeDisplayStore.ts
Normal file
110
src/stores/nodeDisplayStore.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
interface NodeDisplayFlags {
|
||||
collapsed?: boolean
|
||||
pinned?: boolean
|
||||
ghost?: boolean
|
||||
}
|
||||
|
||||
export interface NodeDisplayState {
|
||||
id: NodeId
|
||||
title: string
|
||||
mode: number
|
||||
shape?: number
|
||||
showAdvanced?: boolean
|
||||
color?: string
|
||||
bgcolor?: string
|
||||
flags: NodeDisplayFlags
|
||||
}
|
||||
|
||||
export type NodeDisplayUpdate = Partial<Omit<NodeDisplayState, 'id'>>
|
||||
|
||||
export const useNodeDisplayStore = defineStore('nodeDisplay', () => {
|
||||
const graphDisplayStates = ref(new Map<UUID, Map<NodeId, NodeDisplayState>>())
|
||||
|
||||
function getDisplayMap(graphId: UUID): Map<NodeId, NodeDisplayState> {
|
||||
const existing = graphDisplayStates.value.get(graphId)
|
||||
if (existing) return existing
|
||||
|
||||
const next = reactive(new Map<NodeId, NodeDisplayState>())
|
||||
graphDisplayStates.value.set(graphId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
function registerNode(
|
||||
graphId: UUID,
|
||||
nodeId: NodeId,
|
||||
initial: NodeDisplayState
|
||||
): void {
|
||||
getDisplayMap(graphId).set(nodeId, { ...initial })
|
||||
}
|
||||
|
||||
function updateNode(
|
||||
graphId: UUID,
|
||||
nodeId: NodeId,
|
||||
update: NodeDisplayUpdate
|
||||
): void {
|
||||
const displayMap = getDisplayMap(graphId)
|
||||
const existing = displayMap.get(nodeId)
|
||||
if (!existing) return
|
||||
|
||||
let changed = false
|
||||
|
||||
for (const key of Object.keys(update) as Array<keyof NodeDisplayUpdate>) {
|
||||
if (key === 'flags') {
|
||||
const flagUpdate = update.flags
|
||||
if (!flagUpdate) continue
|
||||
|
||||
const flagsChanged = Object.keys(flagUpdate).some(
|
||||
(fk) =>
|
||||
existing.flags[fk as keyof NodeDisplayFlags] !==
|
||||
flagUpdate[fk as keyof NodeDisplayFlags]
|
||||
)
|
||||
|
||||
if (flagsChanged) {
|
||||
existing.flags = { ...existing.flags, ...flagUpdate }
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (existing[key] !== update[key]) {
|
||||
Object.assign(existing, { [key]: update[key] })
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
displayMap.set(nodeId, existing)
|
||||
}
|
||||
}
|
||||
|
||||
function removeNode(graphId: UUID, nodeId: NodeId): void {
|
||||
const displayMap = graphDisplayStates.value.get(graphId)
|
||||
displayMap?.delete(nodeId)
|
||||
}
|
||||
|
||||
function getNode(
|
||||
graphId: UUID,
|
||||
nodeId: NodeId
|
||||
): NodeDisplayState | undefined {
|
||||
return graphDisplayStates.value.get(graphId)?.get(nodeId)
|
||||
}
|
||||
|
||||
function clearGraph(graphId: UUID): void {
|
||||
graphDisplayStates.value.delete(graphId)
|
||||
}
|
||||
|
||||
return {
|
||||
getDisplayMap,
|
||||
registerNode,
|
||||
updateNode,
|
||||
removeNode,
|
||||
getNode,
|
||||
clearGraph
|
||||
}
|
||||
})
|
||||
@@ -10,11 +10,6 @@ import { app } from '@/scripts/app'
|
||||
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
|
||||
import { isNonNullish } from '@/utils/typeGuardUtil'
|
||||
|
||||
/**
|
||||
* Stores the current subgraph navigation state; a stack representing subgraph
|
||||
* navigation history from the root graph to the subgraph that is currently
|
||||
* open.
|
||||
*/
|
||||
export const useSubgraphNavigationStore = defineStore(
|
||||
'subgraphNavigation',
|
||||
() => {
|
||||
|
||||
Reference in New Issue
Block a user