Skip to content

Commit aabea4b

Browse files
[feat] Viewport persistence for subgraph navigation (#4613)
1 parent f85df30 commit aabea4b

File tree

2 files changed

+311
-3
lines changed

2 files changed

+311
-3
lines changed

src/stores/subgraphNavigationStore.ts

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import QuickLRU from '@alloc/quick-lru'
12
import type { Subgraph } from '@comfyorg/litegraph'
3+
import type { DragAndScaleState } from '@comfyorg/litegraph/dist/DragAndScale'
24
import { defineStore } from 'pinia'
35
import { computed, shallowReactive, shallowRef, watch } from 'vue'
46

57
import { app } from '@/scripts/app'
68
import { isNonNullish } from '@/utils/typeGuardUtil'
79

10+
import { useCanvasStore } from './graphStore'
811
import { useWorkflowStore } from './workflowStore'
912

1013
/**
@@ -16,13 +19,19 @@ export const useSubgraphNavigationStore = defineStore(
1619
'subgraphNavigation',
1720
() => {
1821
const workflowStore = useWorkflowStore()
22+
const canvasStore = useCanvasStore()
1923

2024
/** The currently opened subgraph. */
2125
const activeSubgraph = shallowRef<Subgraph>()
2226

2327
/** The stack of subgraph IDs from the root graph to the currently opened subgraph. */
2428
const idStack = shallowReactive<string[]>([])
2529

30+
/** LRU cache for viewport states. Key: subgraph ID or 'root' for root graph */
31+
const viewportCache = new QuickLRU<string, DragAndScaleState>({
32+
maxSize: 32
33+
})
34+
2635
/**
2736
* A stack representing subgraph navigation history from the root graph to
2837
* the current opened subgraph.
@@ -48,19 +57,73 @@ export const useSubgraphNavigationStore = defineStore(
4857
*/
4958
const exportState = () => [...idStack]
5059

60+
/**
61+
* Get the current viewport state.
62+
* @returns The current viewport state, or null if the canvas is not available.
63+
*/
64+
const getCurrentViewport = (): DragAndScaleState | null => {
65+
const canvas = canvasStore.getCanvas()
66+
if (!canvas) return null
67+
68+
return {
69+
scale: canvas.ds.state.scale,
70+
offset: [...canvas.ds.state.offset]
71+
}
72+
}
73+
74+
/**
75+
* Save the current viewport state.
76+
* @param graphId The graph ID to save for. Use 'root' for root graph, or omit to use current context.
77+
*/
78+
const saveViewport = (graphId: string) => {
79+
const viewport = getCurrentViewport()
80+
if (!viewport) return
81+
82+
viewportCache.set(graphId, viewport)
83+
}
84+
85+
/**
86+
* Restore viewport state for a graph.
87+
* @param graphId The graph ID to restore. Use 'root' for root graph, or omit to use current context.
88+
*/
89+
const restoreViewport = (graphId: string) => {
90+
const viewport = viewportCache.get(graphId)
91+
if (!viewport) return
92+
93+
const canvas = app.canvas
94+
if (!canvas) return
95+
96+
canvas.ds.scale = viewport.scale
97+
canvas.ds.offset[0] = viewport.offset[0]
98+
canvas.ds.offset[1] = viewport.offset[1]
99+
canvas.setDirty(true, true)
100+
}
101+
51102
// Reset on workflow change
52103
watch(
53104
() => workflowStore.activeWorkflow,
54-
() => (idStack.length = 0)
105+
() => {
106+
idStack.length = 0
107+
}
55108
)
56109

57110
// Update navigation stack when opened subgraph changes
58111
watch(
59112
() => workflowStore.activeSubgraph,
60-
(subgraph) => {
113+
(subgraph, prevSubgraph) => {
114+
// Save viewport state for the graph we're leaving
115+
if (prevSubgraph) {
116+
// Leaving a subgraph
117+
saveViewport(prevSubgraph.id)
118+
} else if (!prevSubgraph && subgraph) {
119+
// Leaving root graph to enter a subgraph
120+
saveViewport('root')
121+
}
122+
61123
// Navigated back to the root graph
62124
if (!subgraph) {
63125
idStack.length = 0
126+
restoreViewport('root')
64127
return
65128
}
66129

@@ -74,14 +137,20 @@ export const useSubgraphNavigationStore = defineStore(
74137
// Navigated to a different subgraph
75138
idStack.splice(index + 1, lastIndex - index)
76139
}
140+
141+
// Always try to restore viewport for the target subgraph
142+
restoreViewport(subgraph.id)
77143
}
78144
)
79145

80146
return {
81147
activeSubgraph,
82148
navigationStack,
83149
restoreState,
84-
exportState
150+
exportState,
151+
saveViewport,
152+
restoreViewport,
153+
viewportCache
85154
}
86155
}
87156
)
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { createPinia, setActivePinia } from 'pinia'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { nextTick } from 'vue'
4+
5+
import { app } from '@/scripts/app'
6+
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
7+
import { useWorkflowStore } from '@/stores/workflowStore'
8+
import type { ComfyWorkflow } from '@/stores/workflowStore'
9+
10+
vi.mock('@/scripts/app', () => {
11+
const mockCanvas = {
12+
subgraph: null,
13+
ds: {
14+
scale: 1,
15+
offset: [0, 0],
16+
state: {
17+
scale: 1,
18+
offset: [0, 0]
19+
}
20+
},
21+
setDirty: vi.fn()
22+
}
23+
24+
return {
25+
app: {
26+
graph: {
27+
subgraphs: new Map(),
28+
getNodeById: vi.fn()
29+
},
30+
canvas: mockCanvas
31+
}
32+
}
33+
})
34+
35+
// Mock canvasStore
36+
vi.mock('@/stores/graphStore', () => ({
37+
useCanvasStore: () => ({
38+
getCanvas: () => (app as any).canvas
39+
})
40+
}))
41+
42+
// Get reference to mock canvas
43+
const mockCanvas = app.canvas as any
44+
45+
describe('useSubgraphNavigationStore - Viewport Persistence', () => {
46+
beforeEach(() => {
47+
setActivePinia(createPinia())
48+
// Reset canvas state
49+
mockCanvas.ds.scale = 1
50+
mockCanvas.ds.offset = [0, 0]
51+
mockCanvas.ds.state.scale = 1
52+
mockCanvas.ds.state.offset = [0, 0]
53+
mockCanvas.setDirty.mockClear()
54+
})
55+
56+
describe('saveViewport', () => {
57+
it('should save viewport state for root graph', () => {
58+
const navigationStore = useSubgraphNavigationStore()
59+
60+
// Set viewport state
61+
mockCanvas.ds.state.scale = 2
62+
mockCanvas.ds.state.offset = [100, 200]
63+
64+
// Save viewport for root
65+
navigationStore.saveViewport('root')
66+
67+
// Check it was saved
68+
const saved = navigationStore.viewportCache.get('root')
69+
expect(saved).toEqual({
70+
scale: 2,
71+
offset: [100, 200]
72+
})
73+
})
74+
75+
it('should save viewport state for subgraph', () => {
76+
const navigationStore = useSubgraphNavigationStore()
77+
78+
// Set viewport state
79+
mockCanvas.ds.state.scale = 1.5
80+
mockCanvas.ds.state.offset = [50, 75]
81+
82+
// Save viewport for subgraph
83+
navigationStore.saveViewport('subgraph-123')
84+
85+
// Check it was saved
86+
const saved = navigationStore.viewportCache.get('subgraph-123')
87+
expect(saved).toEqual({
88+
scale: 1.5,
89+
offset: [50, 75]
90+
})
91+
})
92+
93+
it('should save viewport for current context when no ID provided', () => {
94+
const navigationStore = useSubgraphNavigationStore()
95+
const workflowStore = useWorkflowStore()
96+
97+
// Mock being in a subgraph
98+
const mockSubgraph = { id: 'sub-456' }
99+
workflowStore.activeSubgraph = mockSubgraph as any
100+
101+
// Set viewport state
102+
mockCanvas.ds.state.scale = 3
103+
mockCanvas.ds.state.offset = [10, 20]
104+
105+
// Save viewport without ID (should default to root since activeSubgraph is not tracked by navigation store)
106+
navigationStore.saveViewport('sub-456')
107+
108+
// Should save for the specified subgraph
109+
const saved = navigationStore.viewportCache.get('sub-456')
110+
expect(saved).toEqual({
111+
scale: 3,
112+
offset: [10, 20]
113+
})
114+
})
115+
})
116+
117+
describe('restoreViewport', () => {
118+
it('should restore viewport state for root graph', () => {
119+
const navigationStore = useSubgraphNavigationStore()
120+
121+
// Save a viewport state
122+
navigationStore.viewportCache.set('root', {
123+
scale: 2.5,
124+
offset: [150, 250]
125+
})
126+
127+
// Restore it
128+
navigationStore.restoreViewport('root')
129+
130+
// Check canvas was updated
131+
expect(mockCanvas.ds.scale).toBe(2.5)
132+
expect(mockCanvas.ds.offset).toEqual([150, 250])
133+
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
134+
})
135+
136+
it('should restore viewport state for subgraph', () => {
137+
const navigationStore = useSubgraphNavigationStore()
138+
139+
// Save a viewport state
140+
navigationStore.viewportCache.set('sub-789', {
141+
scale: 0.75,
142+
offset: [-50, -100]
143+
})
144+
145+
// Restore it
146+
navigationStore.restoreViewport('sub-789')
147+
148+
// Check canvas was updated
149+
expect(mockCanvas.ds.scale).toBe(0.75)
150+
expect(mockCanvas.ds.offset).toEqual([-50, -100])
151+
})
152+
153+
it('should do nothing if no saved viewport exists', () => {
154+
const navigationStore = useSubgraphNavigationStore()
155+
156+
// Reset canvas
157+
mockCanvas.ds.scale = 1
158+
mockCanvas.ds.offset = [0, 0]
159+
mockCanvas.setDirty.mockClear()
160+
161+
// Try to restore non-existent viewport
162+
navigationStore.restoreViewport('non-existent')
163+
164+
// Canvas should not change
165+
expect(mockCanvas.ds.scale).toBe(1)
166+
expect(mockCanvas.ds.offset).toEqual([0, 0])
167+
expect(mockCanvas.setDirty).not.toHaveBeenCalled()
168+
})
169+
})
170+
171+
describe('navigation integration', () => {
172+
it('should save and restore viewport when navigating between subgraphs', async () => {
173+
const navigationStore = useSubgraphNavigationStore()
174+
const workflowStore = useWorkflowStore()
175+
176+
// Start at root with custom viewport
177+
mockCanvas.ds.state.scale = 2
178+
mockCanvas.ds.state.offset = [100, 100]
179+
180+
// Navigate to subgraph
181+
const subgraph1 = { id: 'sub1' }
182+
workflowStore.activeSubgraph = subgraph1 as any
183+
await nextTick()
184+
185+
// Root viewport should have been saved
186+
const rootViewport = navigationStore.viewportCache.get('root')
187+
expect(rootViewport).toBeDefined()
188+
expect(rootViewport?.scale).toBe(2)
189+
expect(rootViewport?.offset).toEqual([100, 100])
190+
191+
// Change viewport in subgraph
192+
mockCanvas.ds.state.scale = 0.5
193+
mockCanvas.ds.state.offset = [-50, -50]
194+
195+
// Navigate back to root
196+
workflowStore.activeSubgraph = undefined
197+
await nextTick()
198+
199+
// Subgraph viewport should have been saved
200+
const sub1Viewport = navigationStore.viewportCache.get('sub1')
201+
expect(sub1Viewport).toBeDefined()
202+
expect(sub1Viewport?.scale).toBe(0.5)
203+
expect(sub1Viewport?.offset).toEqual([-50, -50])
204+
205+
// Root viewport should be restored
206+
expect(mockCanvas.ds.scale).toBe(2)
207+
expect(mockCanvas.ds.offset).toEqual([100, 100])
208+
})
209+
210+
it('should preserve viewport cache when switching workflows', async () => {
211+
const navigationStore = useSubgraphNavigationStore()
212+
const workflowStore = useWorkflowStore()
213+
214+
// Add some viewport states
215+
navigationStore.viewportCache.set('root', { scale: 2, offset: [0, 0] })
216+
navigationStore.viewportCache.set('sub1', {
217+
scale: 1.5,
218+
offset: [10, 10]
219+
})
220+
221+
expect(navigationStore.viewportCache.size).toBe(2)
222+
223+
// Switch workflows
224+
const workflow1 = { path: 'workflow1.json' } as ComfyWorkflow
225+
const workflow2 = { path: 'workflow2.json' } as ComfyWorkflow
226+
227+
workflowStore.activeWorkflow = workflow1 as any
228+
await nextTick()
229+
230+
workflowStore.activeWorkflow = workflow2 as any
231+
await nextTick()
232+
233+
// Cache should be preserved (LRU will manage memory)
234+
expect(navigationStore.viewportCache.size).toBe(2)
235+
expect(navigationStore.viewportCache.has('root')).toBe(true)
236+
expect(navigationStore.viewportCache.has('sub1')).toBe(true)
237+
})
238+
})
239+
})

0 commit comments

Comments
 (0)