From a2940d6a181fb640d03546f09c619d548aa2ad11 Mon Sep 17 00:00:00 2001 From: Rizumu Ayaka Date: Wed, 14 Jan 2026 16:58:27 +0800 Subject: [PATCH] refactor: centralized node mode management --- .../rightSidePanel/settings/SetNodeState.vue | 43 +++---- .../canvas/useSelectedLiteGraphItems.test.ts | 120 +++++++++++++++--- .../canvas/useSelectedLiteGraphItems.ts | 18 ++- .../core/layout/operations/layoutMutations.ts | 1 + .../core/layout/store/layoutStore.test.ts | 1 + src/renderer/core/layout/store/layoutStore.ts | 72 ++++++++++- src/renderer/core/layout/types.ts | 12 ++ .../core/layout/utils/layoutMath.test.ts | 1 + src/renderer/core/layout/utils/mappers.ts | 3 + .../minimap/data/MinimapDataSource.test.ts | 1 + .../useNodePointerInteractions.test.ts | 1 + 11 files changed, 223 insertions(+), 50 deletions(-) diff --git a/src/components/rightSidePanel/settings/SetNodeState.vue b/src/components/rightSidePanel/settings/SetNodeState.vue index 7d64fefef..a49ba2191 100644 --- a/src/components/rightSidePanel/settings/SetNodeState.vue +++ b/src/components/rightSidePanel/settings/SetNodeState.vue @@ -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 - -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') } }) diff --git a/src/composables/canvas/useSelectedLiteGraphItems.test.ts b/src/composables/canvas/useSelectedLiteGraphItems.test.ts index 23e1e8dd3..7f164245a 100644 --- a/src/composables/canvas/useSelectedLiteGraphItems.test.ts +++ b/src/composables/canvas/useSelectedLiteGraphItems.test.ts @@ -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 + ) }) }) diff --git a/src/composables/canvas/useSelectedLiteGraphItems.ts b/src/composables/canvas/useSelectedLiteGraphItems.ts index a4a93b9fb..0e165f520 100644 --- a/src/composables/canvas/useSelectedLiteGraphItems.ts +++ b/src/composables/canvas/useSelectedLiteGraphItems.ts @@ -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 } }) diff --git a/src/renderer/core/layout/operations/layoutMutations.ts b/src/renderer/core/layout/operations/layoutMutations.ts index 78d580d35..b2d0e51f0 100644 --- a/src/renderer/core/layout/operations/layoutMutations.ts +++ b/src/renderer/core/layout/operations/layoutMutations.ts @@ -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, diff --git a/src/renderer/core/layout/store/layoutStore.test.ts b/src/renderer/core/layout/store/layoutStore.test.ts index ff7ee1fa0..4bfb04432 100644 --- a/src/renderer/core/layout/store/layoutStore.test.ts +++ b/src/renderer/core/layout/store/layoutStore.test.ts @@ -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 } }) diff --git a/src/renderer/core/layout/store/layoutStore.ts b/src/renderer/core/layout/store/layoutStore.ts index 786be0625..44924c25b 100644 --- a/src/renderer/core/layout/store/layoutStore.ts +++ b/src/renderer/core/layout/store/layoutStore.ts @@ -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. */ diff --git a/src/renderer/core/layout/types.ts b/src/renderer/core/layout/types.ts index 4332059f5..0ad001d10 100644 --- a/src/renderer/core/layout/types.ts +++ b/src/renderer/core/layout/types.ts @@ -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 diff --git a/src/renderer/core/layout/utils/layoutMath.test.ts b/src/renderer/core/layout/utils/layoutMath.test.ts index 9c1fe1b46..ccec019ec 100644 --- a/src/renderer/core/layout/utils/layoutMath.test.ts +++ b/src/renderer/core/layout/utils/layoutMath.test.ts @@ -62,6 +62,7 @@ describe('layoutMath utils', () => { size: { width, height }, zIndex: 0, visible: true, + mode: 0, bounds: { x, y, width, height } }) diff --git a/src/renderer/core/layout/utils/mappers.ts b/src/renderer/core/layout/utils/mappers.ts index 48b85dc53..0aeaa00cb 100644 --- a/src/renderer/core/layout/utils/mappers.ts +++ b/src/renderer/core/layout/utils/mappers.ts @@ -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) } } diff --git a/src/renderer/extensions/minimap/data/MinimapDataSource.test.ts b/src/renderer/extensions/minimap/data/MinimapDataSource.test.ts index 50f9ebba0..2296edf7c 100644 --- a/src/renderer/extensions/minimap/data/MinimapDataSource.test.ts +++ b/src/renderer/extensions/minimap/data/MinimapDataSource.test.ts @@ -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 } } ] diff --git a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts index c3410051a..f4d2e6c73 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts +++ b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts @@ -80,6 +80,7 @@ const mockData = vi.hoisted(() => { size: { width: 100, height: 100 }, zIndex: 1, visible: true, + mode: 0, bounds: { x: 0, y: 0,