refactor: centralized node mode management

This commit is contained in:
Rizumu Ayaka
2026-01-14 16:58:27 +08:00
parent 6382b1e099
commit a2940d6a18
11 changed files with 223 additions and 50 deletions

View File

@@ -4,44 +4,41 @@ import { useI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import FormSelectButton from '@/renderer/extensions/vueNodes/widgets/components/form/FormSelectButton.vue'
import LayoutField from './LayoutField.vue'
/**
* Good design limits dependencies and simplifies the interface of the abstraction layer.
* Here, we only care about the mode method,
* and do not concern ourselves with other methods.
*/
type PickedNode = Pick<LGraphNode, 'mode'>
const { nodes } = defineProps<{ nodes: PickedNode[] }>()
const { nodes } = defineProps<{ nodes: LGraphNode[] }>()
const emit = defineEmits<{ (e: 'changed'): void }>()
const { t } = useI18n()
const nodeState = computed({
get() {
let mode: LGraphNode['mode'] | null = null
if (nodes.length === 0) return null
// For multiple nodes, if all nodes have the same mode, return that mode, otherwise return null
if (nodes.length > 1) {
mode = nodes[0].mode
if (!nodes.every((node) => node.mode === mode)) {
mode = null
}
} else {
mode = nodes[0].mode
}
const nodeIds = nodes.map((node) => node.id.toString())
const modes = nodeIds
.map((nodeId) => {
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
return nodeRef.value?.mode
})
.filter((mode): mode is number => mode !== undefined && mode !== null)
return mode
if (modes.length === 0) return null
// For multiple nodes, if all nodes have the same mode, return that mode, otherwise return null
const firstMode = modes[0]
const allSame = modes.every((mode) => mode === firstMode)
return allSame ? firstMode : null
},
set(value: LGraphNode['mode']) {
nodes.forEach((node) => {
node.mode = value
})
if (value === null || value === undefined) return
const nodeIds = nodes.map((node) => node.id.toString())
layoutStore.setNodesMode(nodeIds, value)
emit('changed')
}
})

View File

@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
@@ -224,8 +225,19 @@ describe('useSelectedLiteGraphItems', () => {
it('toggleSelectedNodesMode should toggle node modes correctly', () => {
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
const node1 = { id: 1, mode: LGraphEventMode.ALWAYS } as LGraphNode
const node2 = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode
const node1 = { id: '1', mode: LGraphEventMode.ALWAYS } as LGraphNode
const node2 = { id: '2', mode: LGraphEventMode.NEVER } as LGraphNode
// Initialize nodes in layoutStore
layoutStore.initializeFromLiteGraph([
{
id: '1',
pos: [0, 0],
size: [100, 100],
mode: LGraphEventMode.ALWAYS
},
{ id: '2', pos: [0, 0], size: [100, 100], mode: LGraphEventMode.NEVER }
])
app.canvas.selected_nodes = { '0': node1, '1': node2 }
@@ -234,13 +246,22 @@ describe('useSelectedLiteGraphItems', () => {
// node1 should change from ALWAYS to NEVER
// node2 should stay NEVER (since a selected node exists which is not NEVER)
expect(node1.mode).toBe(LGraphEventMode.NEVER)
expect(node2.mode).toBe(LGraphEventMode.NEVER)
expect(layoutStore.getNodeLayoutRef('1').value?.mode).toBe(
LGraphEventMode.NEVER
)
expect(layoutStore.getNodeLayoutRef('2').value?.mode).toBe(
LGraphEventMode.NEVER
)
})
it('toggleSelectedNodesMode should set mode to ALWAYS when already in target mode', () => {
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
const node = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
const node = { id: '1', mode: LGraphEventMode.BYPASS } as LGraphNode
// Initialize node in layoutStore
layoutStore.initializeFromLiteGraph([
{ id: '1', pos: [0, 0], size: [100, 100], mode: LGraphEventMode.BYPASS }
])
app.canvas.selected_nodes = { '0': node }
@@ -248,7 +269,9 @@ describe('useSelectedLiteGraphItems', () => {
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
// Should change to ALWAYS
expect(node.mode).toBe(LGraphEventMode.ALWAYS)
expect(layoutStore.getNodeLayoutRef('1').value?.mode).toBe(
LGraphEventMode.ALWAYS
)
})
it('getSelectedNodes should include nodes from subgraphs', () => {
@@ -277,17 +300,43 @@ describe('useSelectedLiteGraphItems', () => {
it('toggleSelectedNodesMode should apply unified state to subgraph children', () => {
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode
const subNode1 = { id: '11', mode: LGraphEventMode.ALWAYS } as LGraphNode
const subNode2 = { id: '12', mode: LGraphEventMode.NEVER } as LGraphNode
const subgraphNode = {
id: 1,
id: '1',
mode: LGraphEventMode.ALWAYS,
isSubgraphNode: () => true,
subgraph: {
nodes: [subNode1, subNode2]
}
} as unknown as LGraphNode
const regularNode = { id: 2, mode: LGraphEventMode.BYPASS } as LGraphNode
const regularNode = {
id: '2',
mode: LGraphEventMode.BYPASS
} as LGraphNode
// Initialize all nodes in layoutStore
layoutStore.initializeFromLiteGraph([
{
id: '1',
pos: [0, 0],
size: [100, 100],
mode: LGraphEventMode.ALWAYS
},
{
id: '2',
pos: [0, 0],
size: [100, 100],
mode: LGraphEventMode.BYPASS
},
{
id: '11',
pos: [0, 0],
size: [100, 100],
mode: LGraphEventMode.ALWAYS
},
{ id: '12', pos: [0, 0], size: [100, 100], mode: LGraphEventMode.NEVER }
])
app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode }
@@ -296,22 +345,30 @@ describe('useSelectedLiteGraphItems', () => {
// Selected nodes follow standard toggle logic:
// subgraphNode: ALWAYS -> NEVER (since ALWAYS != NEVER)
expect(subgraphNode.mode).toBe(LGraphEventMode.NEVER)
expect(layoutStore.getNodeLayoutRef('1').value?.mode).toBe(
LGraphEventMode.NEVER
)
// regularNode: BYPASS -> NEVER (since BYPASS != NEVER)
expect(regularNode.mode).toBe(LGraphEventMode.NEVER)
expect(layoutStore.getNodeLayoutRef('2').value?.mode).toBe(
LGraphEventMode.NEVER
)
// Subgraph children get unified state (same as their parent):
// Both children should now be NEVER, regardless of their previous states
expect(subNode1.mode).toBe(LGraphEventMode.NEVER) // was ALWAYS, now NEVER
expect(subNode2.mode).toBe(LGraphEventMode.NEVER) // was NEVER, stays NEVER
expect(layoutStore.getNodeLayoutRef('11').value?.mode).toBe(
LGraphEventMode.NEVER
) // was ALWAYS, now NEVER
expect(layoutStore.getNodeLayoutRef('12').value?.mode).toBe(
LGraphEventMode.NEVER
) // was NEVER, stays NEVER
})
it('toggleSelectedNodesMode should toggle to ALWAYS when subgraph is already in target mode', () => {
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
const subNode2 = { id: 12, mode: LGraphEventMode.BYPASS } as LGraphNode
const subNode1 = { id: '11', mode: LGraphEventMode.ALWAYS } as LGraphNode
const subNode2 = { id: '12', mode: LGraphEventMode.BYPASS } as LGraphNode
const subgraphNode = {
id: 1,
id: '1',
mode: LGraphEventMode.NEVER, // Already in NEVER mode
isSubgraphNode: () => true,
subgraph: {
@@ -319,17 +376,40 @@ describe('useSelectedLiteGraphItems', () => {
}
} as unknown as LGraphNode
// Initialize all nodes in layoutStore
layoutStore.initializeFromLiteGraph([
{ id: '1', pos: [0, 0], size: [100, 100], mode: LGraphEventMode.NEVER },
{
id: '11',
pos: [0, 0],
size: [100, 100],
mode: LGraphEventMode.ALWAYS
},
{
id: '12',
pos: [0, 0],
size: [100, 100],
mode: LGraphEventMode.BYPASS
}
])
app.canvas.selected_nodes = { '0': subgraphNode }
// Toggle to NEVER mode (but subgraphNode is already NEVER)
toggleSelectedNodesMode(LGraphEventMode.NEVER)
// Selected subgraph should toggle to ALWAYS (since it was already NEVER)
expect(subgraphNode.mode).toBe(LGraphEventMode.ALWAYS)
expect(layoutStore.getNodeLayoutRef('1').value?.mode).toBe(
LGraphEventMode.ALWAYS
)
// All children should also get ALWAYS (unified with parent's new state)
expect(subNode1.mode).toBe(LGraphEventMode.ALWAYS)
expect(subNode2.mode).toBe(LGraphEventMode.ALWAYS)
expect(layoutStore.getNodeLayoutRef('11').value?.mode).toBe(
LGraphEventMode.ALWAYS
)
expect(layoutStore.getNodeLayoutRef('12').value?.mode).toBe(
LGraphEventMode.ALWAYS
)
})
})

View File

@@ -1,5 +1,6 @@
import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import {
@@ -119,16 +120,21 @@ export function useSelectedLiteGraphItems() {
for (const i in selectedNodes) {
selectedNodeArray.push(selectedNodes[i])
}
const allNodesMatch = !selectedNodeArray.some(
(selectedNode) => selectedNode.mode !== mode
)
// Check if all selected nodes are already in the target mode
const allNodesMatch = !selectedNodeArray.some((selectedNode) => {
const nodeRef = layoutStore.getNodeLayoutRef(selectedNode.id.toString())
return nodeRef.value?.mode !== mode
})
const newModeForSelectedNode = allNodesMatch ? LGraphEventMode.ALWAYS : mode
// Process each selected node independently to determine its target state and apply to children
selectedNodeArray.forEach((selectedNode) => {
// Apply standard toggle logic to the selected node itself
selectedNode.mode = newModeForSelectedNode
layoutStore.setNodeMode(
selectedNode.id.toString(),
newModeForSelectedNode
)
// If this selected node is a subgraph, apply the same mode uniformly to all its children
// This ensures predictable behavior: all children get the same state as their parent
@@ -139,7 +145,7 @@ export function useSelectedLiteGraphItems() {
if (node === selectedNode) return undefined
// Apply the parent's new mode to all children uniformly
node.mode = newModeForSelectedNode
layoutStore.setNodeMode(node.id.toString(), newModeForSelectedNode)
return undefined
}
})

View File

@@ -150,6 +150,7 @@ export function useLayoutMutations(): LayoutMutations {
size: layout.size ?? { width: 200, height: 100 },
zIndex: layout.zIndex ?? 0,
visible: layout.visible ?? true,
mode: layout.mode ?? 0, // Default to ALWAYS
bounds: {
x: layout.position?.x ?? 0,
y: layout.position?.y ?? 0,

View File

@@ -17,6 +17,7 @@ describe('layoutStore CRDT operations', () => {
size: { width: 200, height: 100 },
zIndex: 0,
visible: true,
mode: 0,
bounds: { x: 100, y: 100, width: 200, height: 100 }
})

View File

@@ -37,6 +37,7 @@ import type {
RerouteId,
RerouteLayout,
ResizeNodeOperation,
SetNodeModeOperation,
SetNodeZIndexOperation,
SlotLayout
} from '@/renderer/core/layout/types'
@@ -295,6 +296,18 @@ class LayoutStoreImpl implements LayoutStore {
actor: this.currentActor
})
}
if (existingLayout.mode !== newLayout.mode) {
this.applyOperation({
type: 'setNodeMode',
entity: 'node',
nodeId,
mode: newLayout.mode,
previousMode: existingLayout.mode,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
}
}
trigger()
@@ -870,6 +883,9 @@ class LayoutStoreImpl implements LayoutStore {
case 'setNodeZIndex':
this.handleSetNodeZIndex(operation as SetNodeZIndexOperation, change)
break
case 'setNodeMode':
this.handleSetNodeMode(operation as SetNodeModeOperation, change)
break
case 'createNode':
this.handleCreateNode(operation as CreateNodeOperation, change)
break
@@ -969,7 +985,12 @@ class LayoutStoreImpl implements LayoutStore {
* Initialize store with existing nodes
*/
initializeFromLiteGraph(
nodes: Array<{ id: string; pos: [number, number]; size: [number, number] }>
nodes: Array<{
id: string
pos: [number, number]
size: [number, number]
mode?: number
}>
): void {
this.ydoc.transact(() => {
this.ynodes.clear()
@@ -993,6 +1014,7 @@ class LayoutStoreImpl implements LayoutStore {
size: { width: node.size[0], height: node.size[1] },
zIndex: index,
visible: true,
mode: node.mode ?? 0, // Default to ALWAYS if not provided
bounds: {
x: node.pos[0],
y: node.pos[1],
@@ -1078,6 +1100,17 @@ class LayoutStoreImpl implements LayoutStore {
change.nodeIds.push(operation.nodeId)
}
private handleSetNodeMode(
operation: SetNodeModeOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(operation.nodeId)
if (!ynode) return
ynode.set('mode', operation.mode)
change.nodeIds.push(operation.nodeId)
}
private handleCreateNode(
operation: CreateNodeOperation,
change: LayoutChange
@@ -1428,6 +1461,43 @@ class LayoutStoreImpl implements LayoutStore {
return Y.encodeStateAsUpdate(this.ydoc)
}
/**
* Set the execution mode for a single node.
* Applies the node's changeMode method if available and notifies the graph.
*/
setNodeMode(nodeId: NodeId, mode: number): void {
const ynode = this.ynodes.get(nodeId)
if (!ynode) return
const currentLayout = yNodeToLayout(ynode)
if (currentLayout.mode === mode) return // No change needed
this.applyOperation({
type: 'setNodeMode',
entity: 'node',
nodeId,
mode,
previousMode: currentLayout.mode,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
/**
* Set the execution mode for multiple nodes.
* Applies the mode to all nodes atomically.
*/
setNodesMode(nodeIds: NodeId[], mode: number): void {
if (nodeIds.length === 0) return
// Apply mode to each node
// Note: We could create a batch operation type if needed for better performance
nodeIds.forEach((nodeId) => {
this.setNodeMode(nodeId, mode)
})
}
/**
* Batch update node bounds using Yjs transaction for atomicity.
*/

View File

@@ -48,6 +48,7 @@ export interface NodeLayout {
size: Size
zIndex: number
visible: boolean
mode: number // LGraphEventMode: 0=ALWAYS, 2=NEVER, 4=BYPASS, etc.
// Computed bounds for hit testing
bounds: Bounds
}
@@ -120,6 +121,7 @@ type OperationType =
| 'moveNode'
| 'resizeNode'
| 'setNodeZIndex'
| 'setNodeMode'
| 'createNode'
| 'deleteNode'
| 'setNodeVisibility'
@@ -157,6 +159,15 @@ export interface SetNodeZIndexOperation extends NodeOpBase {
previousZIndex: number
}
/**
* Set node mode operation
*/
export interface SetNodeModeOperation extends NodeOpBase {
type: 'setNodeMode'
mode: number
previousMode: number
}
/**
* Create node operation
*/
@@ -243,6 +254,7 @@ export type LayoutOperation =
| MoveNodeOperation
| ResizeNodeOperation
| SetNodeZIndexOperation
| SetNodeModeOperation
| CreateNodeOperation
| DeleteNodeOperation
| SetNodeVisibilityOperation

View File

@@ -62,6 +62,7 @@ describe('layoutMath utils', () => {
size: { width, height },
zIndex: 0,
visible: true,
mode: 0,
bounds: { x, y, width, height }
})

View File

@@ -10,6 +10,7 @@ export const NODE_LAYOUT_DEFAULTS: NodeLayout = {
size: { width: 100, height: 50 },
zIndex: 0,
visible: true,
mode: 0, // LGraphEventMode.ALWAYS
bounds: { x: 0, y: 0, width: 100, height: 50 }
}
@@ -20,6 +21,7 @@ export function layoutToYNode(layout: NodeLayout): NodeLayoutMap {
ynode.set('size', layout.size)
ynode.set('zIndex', layout.zIndex)
ynode.set('visible', layout.visible)
ynode.set('mode', layout.mode)
ynode.set('bounds', layout.bounds)
return ynode
}
@@ -40,6 +42,7 @@ export function yNodeToLayout(ynode: NodeLayoutMap): NodeLayout {
size: getOr(ynode, 'size', NODE_LAYOUT_DEFAULTS.size),
zIndex: getOr(ynode, 'zIndex', NODE_LAYOUT_DEFAULTS.zIndex),
visible: getOr(ynode, 'visible', NODE_LAYOUT_DEFAULTS.visible),
mode: getOr(ynode, 'mode', NODE_LAYOUT_DEFAULTS.mode),
bounds: getOr(ynode, 'bounds', NODE_LAYOUT_DEFAULTS.bounds)
}
}

View File

@@ -34,6 +34,7 @@ describe('MinimapDataSource', () => {
size: { width: 100, height: 50 },
zIndex: 0,
visible: true,
mode: 0,
bounds: { x: 0, y: 0, width: 100, height: 50 }
}
]

View File

@@ -80,6 +80,7 @@ const mockData = vi.hoisted(() => {
size: { width: 100, height: 100 },
zIndex: 1,
visible: true,
mode: 0,
bounds: {
x: 0,
y: 0,