From 971efe55a420eaa35301c0508d23e34e37f2b22b Mon Sep 17 00:00:00 2001 From: shrimbly Date: Wed, 25 Feb 2026 22:56:40 +1300 Subject: [PATCH] feat(44-03): add dimming utility and store integration - Created dimmingUtils.ts with computeDimmedNodes function - DFS traversal finds all downstream nodes of disabled Switch outputs - Smart cascade: nodes with active inputs stay active - Added dimmedNodeIds state to workflowStore - Added recomputeDimmedNodes action with set equality check - Recomputes on: switch toggle change, edge add/remove, workflow load - Resets dimmedNodeIds on clearWorkflow - Added execution skip logic in executeSingleNode - Dimmed nodes skip execution but preserve previous output --- src/store/utils/dimmingUtils.ts | 83 +++++++++++++++++++++++++++++++++ src/store/workflowStore.ts | 51 ++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 src/store/utils/dimmingUtils.ts diff --git a/src/store/utils/dimmingUtils.ts b/src/store/utils/dimmingUtils.ts new file mode 100644 index 00000000..b1a834bc --- /dev/null +++ b/src/store/utils/dimmingUtils.ts @@ -0,0 +1,83 @@ +import type { WorkflowNode, WorkflowEdge, SwitchNodeData } from "@/types"; + +/** + * Compute set of node IDs that should be visually dimmed. + * A node is dimmed if ALL its input paths trace back to disabled Switch outputs. + * Smart cascade: if a node has at least one active input from a non-disabled source, it stays active. + */ +export function computeDimmedNodes( + nodes: WorkflowNode[], + edges: WorkflowEdge[] +): Set { + // Step 1: Find all nodes that are downstream of disabled Switch outputs + const potentiallyDimmed = new Set(); + + nodes.forEach(node => { + if (node.type !== "switch") return; + const switchData = node.data as SwitchNodeData; + if (!switchData.switches) return; + + switchData.switches.forEach(sw => { + if (sw.enabled) return; // Only process disabled switches + + // Find edges from this disabled output handle + const disabledEdges = edges.filter( + e => e.source === node.id && e.sourceHandle === sw.id + ); + + // DFS traverse downstream from each disabled edge target + disabledEdges.forEach(edge => { + traverseDownstream(edge.target, edges, potentiallyDimmed); + }); + }); + }); + + // Step 2: Smart cascade — remove nodes that have at least one active input + // A node stays active if any incoming edge comes from a source NOT in potentiallyDimmed + // AND that source is not a Switch with a disabled output pointing to this node + const finalDimmed = new Set(); + + potentiallyDimmed.forEach(nodeId => { + const incomingEdges = edges.filter(e => e.target === nodeId); + + // Check if any incoming edge provides an active path + const hasActiveInput = incomingEdges.some(edge => { + // Source is not potentially dimmed — it's an active source + if (!potentiallyDimmed.has(edge.source)) { + // But also check: is this edge coming from a disabled Switch output? + const sourceNode = nodes.find(n => n.id === edge.source); + if (sourceNode?.type === "switch") { + const switchData = sourceNode.data as SwitchNodeData; + const switchEntry = switchData.switches?.find(s => s.id === edge.sourceHandle); + // If this specific switch output is disabled, it's not an active path + if (switchEntry && !switchEntry.enabled) return false; + } + return true; // Active source, not dimmed + } + return false; // Source is also dimmed + }); + + if (!hasActiveInput) { + finalDimmed.add(nodeId); + } + }); + + return finalDimmed; +} + +/** + * DFS traversal to find all downstream nodes from a starting node. + * Uses visited set for cycle detection. + */ +function traverseDownstream( + nodeId: string, + edges: WorkflowEdge[], + visited: Set +): void { + if (visited.has(nodeId)) return; // Cycle detection + visited.add(nodeId); + + edges + .filter(e => e.source === nodeId) + .forEach(edge => traverseDownstream(edge.target, edges, visited)); +} diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 29e3026c..7c429a46 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -58,6 +58,7 @@ import { clearNodeImageRefs, } from "./utils/executionUtils"; import { getConnectedInputsPure, validateWorkflowPure } from "./utils/connectedInputs"; +import { computeDimmedNodes } from "./utils/dimmingUtils"; import { executeAnnotation, executeArray, @@ -336,6 +337,12 @@ interface WorkflowStore { // Canvas navigation settings actions updateCanvasNavigationSettings: (settings: CanvasNavigationSettings) => void; + // Switch dimming state + dimmedNodeIds: Set; + + // Switch dimming actions + recomputeDimmedNodes: () => void; + } let nodeIdCounter = 0; @@ -428,6 +435,9 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ // Canvas navigation settings initial state canvasNavigationSettings: getCanvasNavigationSettings(), + // Switch dimming initial state + dimmedNodeIds: new Set(), + setEdgeStyle: (style: EdgeStyle) => { set({ edgeStyle: style }); }, @@ -480,6 +490,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ }, updateNodeData: (nodeId: string, data: Partial) => { + const node = get().nodes.find((n) => n.id === nodeId); set((state) => ({ nodes: state.nodes.map((node) => node.id === nodeId @@ -488,6 +499,10 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ ) as WorkflowNode[], hasUnsavedChanges: true, })); + // Recompute dimming if this is a switch node and switches data changed + if (node?.type === "switch" && "switches" in data) { + get().recomputeDimmedNodes(); + } }, removeNode: (nodeId: string) => { @@ -524,6 +539,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ const hasMeaningfulChange = changes.some((c) => c.type !== "select"); // Track manual changes only for remove operations (not selection) const hasRemoveChange = changes.some((c) => c.type === "remove"); + const hasAddOrRemove = changes.some((c) => c.type === "add" || c.type === "remove"); set((state) => ({ edges: applyEdgeChanges(changes, state.edges), @@ -533,6 +549,11 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ if (hasRemoveChange) { get().incrementManualChangeCount(); } + + // Recompute dimming when edges are added or removed + if (hasAddOrRemove) { + get().recomputeDimmedNodes(); + } }, onConnect: (connection: Connection, edgeDataOverrides?: Record) => { @@ -550,6 +571,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ }; }); get().incrementManualChangeCount(); + get().recomputeDimmedNodes(); }, addEdgeWithType: (connection: Connection, edgeType: string, edgeDataOverrides?: Record) => { @@ -906,6 +928,18 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ throw new DOMException('Aborted', 'AbortError'); } + // Check if node is dimmed (downstream of disabled Switch output) + const dimmedNodeIds = get().dimmedNodeIds; + if (dimmedNodeIds.has(node.id)) { + // Skip execution — node is dimmed + // Keep previous output visible (don't clear node data) + logger.info('workflow.skip', 'Node skipped (downstream of disabled Switch)', { + nodeId: node.id, + nodeType: node.type, + }); + return; + } + // Check for pause edges on incoming connections (skip if resuming from this exact node) const isResumingThisNode = isResuming && node.id === startFromNodeId; if (!isResumingThisNode) { @@ -1609,6 +1643,9 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ if (!options?.preserveSnapshot) { get().clearSnapshot(); } + + // Recompute dimming after loading workflow + get().recomputeDimmedNodes(); }, clearWorkflow: () => { @@ -1631,6 +1668,8 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ imageRefBasePath: null, // Reset viewed comments when clearing workflow viewedCommentNodeIds: new Set(), + // Reset dimmed nodes + dimmedNodeIds: new Set(), }); get().clearSnapshot(); }, @@ -2131,6 +2170,18 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ saveCanvasNavigationSettings(settings); }, + // Switch dimming actions + recomputeDimmedNodes: () => { + const { nodes, edges } = get(); + const newDimmed = computeDimmedNodes(nodes, edges); + // Only update if set contents changed (prevent unnecessary rerenders) + const currentDimmed = get().dimmedNodeIds; + if (newDimmed.size !== currentDimmed.size || + [...newDimmed].some(id => !currentDimmed.has(id))) { + set({ dimmedNodeIds: newDimmed }); + } + }, + }); export const useWorkflowStore = create()(workflowStoreImpl);