Compare commits

...

23 Commits

Author SHA1 Message Date
GitHub Action
231184f0bf [automated] Apply ESLint and Oxfmt fixes 2026-03-08 00:58:47 +00:00
Alexander Brown
44e3f9a7b6 fix: resolve test failures from SSOT migration
- Make graphId lazy in extractVueNodeData computed so nodes not yet attached to graph resolve correctly (fixes ghost placement + error state tests)

- Add optional chaining for rootGraph access in LGraphNode and useMinimapGraph (fixes minimap unit test crashes)

- Set up testing Pinia in litegraph test fixtures and ColorWidget tests

Amp-Thread-ID: https://ampcode.com/threads/T-019ccad5-5703-772f-876e-0f1530481bcb
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 16:53:06 -08:00
Alexander Brown
a631e7937f fix: remove redundant cleanup(), use disposeNodeManagerAndSyncs directly
cleanup() bypassed nodeDisplayStore.clearGraph(), leaving stale display entries after unmount. Removed the redundant function and updated GraphCanvas to call disposeNodeManagerAndSyncs() directly.

Amp-Thread-ID: https://ampcode.com/threads/T-019cca90-0bae-717a-aac7-4ad8908a0ed7
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 15:30:46 -08:00
Alexander Brown
238da67eee refactor: remove unnecessary comments across 10 files
Remove migration breadcrumbs, restating JSDoc, CRDT future-work claims, and comment-only additions that violate self-documenting code guidelines.

Amp-Thread-ID: https://ampcode.com/threads/T-019cca7c-2b1f-741a-b464-e454ac5215e7
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 15:05:34 -08:00
Alexander Brown
5bad07f610 refactor: apply PR review fixes for node layout SSOT
- Add private modifier to _title backing field

- Remove pres() wrapper, use presentationRef.value directly

- Replace unsafe cast with Object.assign in nodeDisplayStore

- Inline syncDisplayStore into applyPresentationChange

- Simplify getMinZIndex to use Math.min like getMaxZIndex

- Pre-build zIndex map before sort in useLayoutSync

Amp-Thread-ID: https://ampcode.com/threads/T-019cca5a-87df-7179-b8e5-820f0453b730
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 14:26:55 -08:00
Alexander Brown
d4767e65de refactor: simplicity cleanup — remove dead code and YAGNI markers
- Delete unused projectLayoutToSerialized/projectPresentationToSerialized
- Remove unused LGraphTriggerAction/LGraphTriggerParam barrel re-exports
- Remove isViewStateOnly YAGNI marker and change-detector test
- Remove unused version ref from nodeSelectionStore
- Un-export NodeDisplayFlags (internal only)
- Gate verifyStoreConsistency behind import.meta.env.DEV

Amp-Thread-ID: https://ampcode.com/threads/T-019cca2f-7ef0-70d3-b8f7-975989767adf
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 13:41:58 -08:00
Alexander Brown
12992c51ed merge: resolve conflict in app.ts, take origin/main
Kept try/finally structure with ChangeTracker.isLoadingGraph guard from main

Amp-Thread-ID: https://ampcode.com/threads/T-019cca21-24e3-771f-b386-6731222e3dfa
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 13:09:33 -08:00
Alexander Brown
07c844aa6b refactor: remove old nodePresentationStore and dead types
Amp-Thread-ID: https://ampcode.com/threads/T-019cca09-51fb-732b-a7af-1f3b9e269134
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 12:57:26 -08:00
Alexander Brown
a9537a7450 refactor: migrate useMinimapGraph to useNodeDisplayStore
Amp-Thread-ID: https://ampcode.com/threads/T-019cca09-51fb-732b-a7af-1f3b9e269134
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 12:51:05 -08:00
Alexander Brown
e03ffecf5b refactor: migrate useVueNodeLifecycle to useNodeDisplayStore
Amp-Thread-ID: https://ampcode.com/threads/T-019cca09-51fb-732b-a7af-1f3b9e269134
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 12:49:25 -08:00
Alexander Brown
f91a42a4a7 refactor: migrate LGraphNode to useNodeDisplayStore
Amp-Thread-ID: https://ampcode.com/threads/T-019cca09-51fb-732b-a7af-1f3b9e269134
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 12:47:08 -08:00
Alexander Brown
27c72113db refactor: migrate useGraphNodeManager to useNodeDisplayStore
Amp-Thread-ID: https://ampcode.com/threads/T-019cc9fd-772b-746e-92ac-f12858267c22
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 12:39:15 -08:00
Alexander Brown
81860fc379 test: add useNodeDisplayStore tests
Amp-Thread-ID: https://ampcode.com/threads/T-019cc9fd-772b-746e-92ac-f12858267c22
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 12:34:00 -08:00
Alexander Brown
b7721f9247 feat: add useNodeDisplayStore Pinia store
Amp-Thread-ID: https://ampcode.com/threads/T-019cc9fd-772b-746e-92ac-f12858267c22
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 12:32:17 -08:00
Alexander Brown
86963c9435 refactor: migrate presentation state to nodePresentationStore
- Remove LGraphNodeProperties and node:property:changed trigger
- Presentation setters on LGraphNode sync directly to nodePresentationStore
- VueNodeData reads presentation fields via store-backed getters
- Delete applyStorePresentationProjection (zero runtime callers)
- Delete applyPresentationStoreChange subscription-and-copy mechanism
- Make resizable a derived getter (!pinned && _resizable !== false)
- Minimap listens to nodePresentationStore.onChange for visual updates
- Add behavioral guard tests for presentation tracking

Amp-Thread-ID: https://ampcode.com/threads/T-019cc9b4-739e-7541-9ec4-22fb45addfb2
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 11:44:23 -08:00
Alexander Brown
8119bb45fb fix: z-index sync and presentation store ref cache consolidation
- Sync graph._nodes render order from store zIndex on setNodeZIndex operations

- Consolidate nodeRefs + nodeTriggers into single nodeRefCache map

- Clean up ref cache on removeNode to prevent memory leak

- Add test for remove-then-reinit ref lifecycle

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019cc766-d450-7778-86b7-931180d6ce68
2026-03-07 01:04:32 -08:00
Alexander Brown
29c6a37e8b feat: Phase 7 - group scope decision and out-of-scope invariants
- Decision: groups remain legacy-owned (LiteGraph-authoritative)

- Add 9 scope invariant tests verifying layout store exclusion boundary

- Add contract JSDoc to LGraphGroup referencing decision record

- No group operations, methods, or state in layoutStore domain

Amp-Thread-ID: https://ampcode.com/threads/T-019cc65d-7478-71e4-8b9e-cfdd86a322bb
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 00:24:37 -08:00
Alexander Brown
177535b1b5 feat: Phase 6 — post-configure stabilization checkpoint, clipboard store-aware hydration, serialization regression tests
Task 6.1: Add stabilization checkpoint comment in loadGraphData after all post-configure work completes. Document configured event timing in LGraph.ts. Update notifyStoresAfterConfigure JSDoc.

Task 6.2: Document store hydration contract in _deserializeItems (4-step pipeline). Add contract references in usePaste/useCopy.

Task 6.3: Add 8 regression tests covering serialization field preservation, layout persistence adapter round-trips, and clipboard data passthrough.

Amp-Thread-ID: https://ampcode.com/threads/T-019cc644-03a2-74a9-b7b5-b1c693589b87
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 00:24:36 -08:00
Alexander Brown
c39151d653 feat: phase 5 — store-aware persistence/history contracts
- Add layoutPersistenceAdapter with pure Store↔ISerialisedNode converters
- Add LGraphNode.notifyStoresAfterConfigure() for post-configure store projection
- Add verifyStoreConsistency() to changeTracker for hybrid history contract
- Mark subgraphNavigationStore as view-state only with contract marker
- Add changeTracker.storeContract tests (13) and viewport boundary tests (9)

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019cc75f-f06c-701e-a2b4-ab9fb969ea82
2026-03-07 00:24:29 -08:00
Alexander Brown
ecedeb1515 feat: phase 4 — canonicalize z-order, minimap projection, selection store
Task 4.1: add getMaxZIndex/getMinZIndex to layoutStore, sendNodeToBack mutation, z-order tests

Task 4.2: wire LGraphCanvas bringToFront/sendToBack to layoutStore, sort minimap by zIndex

Task 4.3: create nodeSelectionStore as canonical reactive selection authority, wire canvasStore sync

Amp-Thread-ID: https://ampcode.com/threads/T-019cc617-ccf0-77df-961e-831e7ca07216
Co-authored-by: Amp <amp@ampcode.com>
2026-03-06 18:37:53 -08:00
Alexander Brown
90414f1da9 feat: Phase 3 — NodePresentationStore SSOT foundation
- Create NodePresentationStore domain (types, store, actions, tests)
- Add dedup guard to LGraphNodeProperties._emitPropertyChange
- Add suppressEvents flag for store→LiteGraph projection
- Add LGraphNode.applyStorePresentationProjection() method
- Wire presentation store init/teardown into node lifecycle bridge
- Forward property change events to presentation store

Amp-Thread-ID: https://ampcode.com/threads/T-019cc5fa-8d65-704c-be27-e4e76cdfc3d8
Co-authored-by: Amp <amp@ampcode.com>
2026-03-06 18:18:55 -08:00
Alexander Brown
e421d1dc01 feat: harden core mutation contracts (Phase 2 SSOT)
- Add LGraphNode.applyStoreProjection() for explicit store→LiteGraph
  projection without triggering setter feedback loops
- Refactor useLayoutSync to use applyStoreProjection instead of direct
  array-element writes, making one-way sync self-documenting
- Add geometry mutation guard tests (13 tests) covering pos/size setter
  store integration, applyStoreProjection bypass, and presentation
  property change events
- Document LGraphNodeProperties instrumentation bypass of class setters

Amp-Thread-ID: https://ampcode.com/threads/T-019cc5ee-e659-778b-a92c-eff2e38e1e13
Co-authored-by: Amp <amp@ampcode.com>
2026-03-06 17:46:34 -08:00
Alexander Brown
7e2fb8977c feat: instrument node mutation pathways for SSOT migration (Phase 1)
- Replace direct node.size[n] element writes with setter-triggering
  assignments in dynamicWidgets.ts and widgetInputs.ts
- Add title/mode getter/setter pairs to LGraphNode with
  node:property:changed event firing (matching existing shape pattern)
- Add clarifying comment to ensureCorrectLayoutScale.ts explaining
  intentional batch-write optimization

Amp-Thread-ID: https://ampcode.com/threads/T-019cc5c5-943a-73ea-9090-16f8f00caa9a
Co-authored-by: Amp <amp@ampcode.com>
2026-03-06 17:34:17 -08:00
37 changed files with 1979 additions and 710 deletions

View File

@@ -561,7 +561,7 @@ onMounted(async () => {
})
onUnmounted(() => {
vueNodeLifecycle.cleanup()
vueNodeLifecycle.disposeNodeManagerAndSyncs()
})
function forwardPanEvent(e: PointerEvent) {
if (

View File

@@ -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')
})
})

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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 })
})
})

View File

@@ -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', () => {

View File

@@ -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 {

View File

@@ -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(() => {

View File

@@ -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') {

View File

@@ -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()
})
})

View File

@@ -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)

View 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)
})
})
})

View 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()
})
})
})

View 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)
})
})
})

View File

@@ -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)

View File

@@ -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()
})
})
})

View File

@@ -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
}
}

View File

@@ -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),

View File

@@ -107,8 +107,6 @@ export {
LGraph,
type GroupNodeConfigEntry,
type GroupNodeWorkflowData,
type LGraphTriggerAction,
type LGraphTriggerParam,
type GraphAddOptions
} from './LGraph'
export type { LGraphTriggerEvent } from './types/graphTriggers'

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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
}
}
}

View File

@@ -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)
})
})

View File

@@ -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)
*/

View File

@@ -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)
})
}

View 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)
})
})

View 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
}
})

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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

View 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)
})
})
})

View File

@@ -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
}

View 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')
})
})

View 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
}
})

View File

@@ -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',
() => {