From ec0be43129f119358eb854fdab0b50ce81c5b7d8 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Wed, 1 Jul 2026 15:57:44 +0200 Subject: [PATCH] Add hide/show functionality for selected nodes in the flow editor --- .../studio/canvas/edgetypes/frank-edge.tsx | 14 ++- .../app/routes/studio/canvas/flow.tsx | 97 ++++++++++++++++++- .../canvas/nodetypes/components/handle.tsx | 3 + .../studio/canvas/nodetypes/frank-node.tsx | 33 ++++++- .../routes/studio/context/node-context.tsx | 19 ++++ .../app/routes/studio/flow-to-xml-parser.ts | 2 + .../app/routes/studio/xml-to-json-parser.ts | 13 ++- src/main/frontend/app/stores/flow-store.ts | 10 ++ .../frontend/app/stores/node-context-store.ts | 10 ++ .../frontend/app/stores/shortcut-store.ts | 1 + 10 files changed, 194 insertions(+), 8 deletions(-) diff --git a/src/main/frontend/app/routes/studio/canvas/edgetypes/frank-edge.tsx b/src/main/frontend/app/routes/studio/canvas/edgetypes/frank-edge.tsx index c17de12b..c61b0c96 100644 --- a/src/main/frontend/app/routes/studio/canvas/edgetypes/frank-edge.tsx +++ b/src/main/frontend/app/routes/studio/canvas/edgetypes/frank-edge.tsx @@ -14,6 +14,7 @@ export type FrankEdgeProperties = { selected?: boolean sourceHandleId?: string | null targetHandleId?: string | null + data?: { label?: string; faded?: boolean } type: string } @@ -28,8 +29,10 @@ export default function FrankEdge({ targetPosition, selected, sourceHandleId, + data, }: Readonly) { const deleteEdge = useFlowStore((state) => state.deleteEdge) + const faded = data?.faded ?? false const sourceHandleType = useFlowStore((state) => { const node = state.nodes.find((n) => n.id === source) @@ -61,13 +64,20 @@ export default function FrankEdge({ return ( <> - +
void }) { const showNodeContextMenu = useNodeContextMenu() const [loading, setLoading] = useState(false) + const hoveredNodeId = useNodeContextStore((state) => state.hoveredNodeId) + const showAllForwards = useNodeContextStore((state) => state.showAllForwards) + const setHoveredNodeId = useNodeContextStore((state) => state.setHoveredNodeId) + const toggleShowAllForwards = useNodeContextStore((state) => state.toggleShowAllForwards) const { isEditing, isDirty, @@ -276,6 +281,54 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { const { nodes, edges, onNodesChange, onEdgesChange, onConnect, onReconnect } = useFlowStore(useShallow(selector)) + const hiddenForwardNodeIds = useMemo(() => { + const ids = new Set() + for (const node of nodes) { + if (isFrankNode(node) && node.data.hiddenForwards) ids.add(node.id) + } + + return ids + }, [nodes]) + + const revealedHiddenIds = useMemo(() => { + const revealed = new Set() + if (hiddenForwardNodeIds.size === 0) return revealed + if (showAllForwards) { + for (const id of hiddenForwardNodeIds) revealed.add(id) + return revealed + } + + if (hoveredNodeId !== null) { + if (hiddenForwardNodeIds.has(hoveredNodeId)) revealed.add(hoveredNodeId) + for (const edge of edges) { + if (edge.source === hoveredNodeId && hiddenForwardNodeIds.has(edge.target)) revealed.add(edge.target) + } + } + + return revealed + }, [edges, hiddenForwardNodeIds, hoveredNodeId, showAllForwards]) + + const displayEdges = useMemo(() => { + if (hiddenForwardNodeIds.size === 0) return edges + const isHiddenAndNotRevealed = (nodeId: string) => + hiddenForwardNodeIds.has(nodeId) && !revealedHiddenIds.has(nodeId) + + return edges.map((edge) => { + const faded = isHiddenAndNotRevealed(edge.source) || isHiddenAndNotRevealed(edge.target) + return faded ? { ...edge, data: { ...edge.data, faded: true } } : edge + }) + }, [edges, hiddenForwardNodeIds, revealedHiddenIds]) + + const displayNodes = useMemo(() => { + if (hiddenForwardNodeIds.size === 0) return nodes + + return nodes.map((node) => { + if (!hiddenForwardNodeIds.has(node.id)) return node + const opacity = revealedHiddenIds.has(node.id) ? 1 : 0.5 + return { ...node, style: { ...node.style, opacity, transition: 'opacity 150ms cubic-bezier(0.4, 0, 0.2, 1)' } } + }) + }, [nodes, hiddenForwardNodeIds, revealedHiddenIds]) + const saveFlow = useCallback(async () => { const { nodes: flowNodes, edges: flowEdges, viewport: flowViewport } = useFlowStore.getState() const flowData = { nodes: flowNodes, edges: flowEdges, viewport: flowViewport } @@ -956,6 +1009,19 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { return true }, [isEditing, showNodeContextMenu]) + const toggleSelectedHidden = useCallback(() => { + const selected = useFlowStore + .getState() + .nodes.filter((node): node is FrankNodeType => Boolean(node.selected) && isFrankNode(node)) + if (selected.length === 0) return false + + const shouldHide = selected.some((node) => !node.data.hiddenForwards) + useFlowStore.getState().setNodesHiddenForwards( + selected.map((node) => node.id), + shouldHide, + ) + }, []) + useShortcut({ 'studio.copy': () => copySelection(), 'studio.paste': () => pasteSelection(), @@ -965,6 +1031,7 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { 'studio.redo-alt': () => useFlowStore.getState().redo(), 'studio.group': () => handleGrouping(), 'studio.ungroup': () => handleUngroup(), + 'studio.hide': () => toggleSelectedHidden(), 'studio.save': () => void saveFlow(), 'studio.close-context': () => closeEditNodeContextOnEscape(), 'studio.delete': () => deleteSelection(), @@ -1089,6 +1156,17 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { ], ) + const handleNodeMouseEnter = useCallback( + (_event: React.MouseEvent, node: FlowNode) => { + setHoveredNodeId(node.id) + }, + [setHoveredNodeId], + ) + + const handleNodeMouseLeave = useCallback(() => { + setHoveredNodeId(null) + }, [setHoveredNodeId]) + const handleEdgeClick = useCallback(() => { setIsMultiSelect(false) setSelectedStickyId(null) @@ -1664,8 +1742,8 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { )} { useFlowStore.getState().setViewport(viewPort) }} @@ -1675,6 +1753,8 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { onReconnect={onReconnect} onNodeClick={handleNodeClick} onNodeDoubleClick={handleNodeDoubleClick} + onNodeMouseEnter={handleNodeMouseEnter} + onNodeMouseLeave={handleNodeMouseLeave} onNodeDragStop={handleNodeDragStop} onEdgeClick={handleEdgeClick} onSelectionChange={handleSelectionChange} @@ -1697,6 +1777,17 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { minZoom={0.2} > + {hiddenForwardNodeIds.size > 0 && ( + + + + )} void absolutePosition: { x: number; y: number } typesAllowed?: Record + dimmed?: boolean } const HANDLE_TYPE_COLOURS: Record = { @@ -75,6 +76,8 @@ export function CustomHandle(properties: Readonly) { backgroundColor: translateHandleTypeToColour(type), border: '1px solid rgba(107, 114, 128, 0.5)', pointerEvents: 'all', + opacity: properties.dimmed ? 0.5 : 1, + transition: 'opacity 150ms cubic-bezier(0.4, 0, 0.2, 1)', }} />
diff --git a/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx b/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx index e5b45cb0..35007937 100644 --- a/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx +++ b/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx @@ -10,7 +10,8 @@ import { } from '@xyflow/react' import DangerIcon from '../../../../../icons/solar/Danger Triangle.svg?react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import useFlowStore from '~/stores/flow-store' +import { useShallow } from 'zustand/react/shallow' +import useFlowStore, { isFrankNode } from '~/stores/flow-store' import { CustomHandle } from '~/routes/studio/canvas/nodetypes/components/handle' import { FlowConfig } from '~/routes/studio/canvas/flow.config' import { useNodeContextMenu } from '~/routes/studio/canvas/node-context-menu-context' @@ -47,6 +48,7 @@ export type FrankNodeType = Node<{ attributes?: Record children: ChildNode[] manuallyResized?: boolean + hiddenForwards?: boolean }> & { width?: number height?: number @@ -95,6 +97,34 @@ export default function FrankNode(properties: NodeProps) { const dangerTriangleReference = useRef(null) const availableHandleTypes = useHandleTypes(frankElement?.forwards) + const hoveredNodeId = useNodeContextStore((state) => state.hoveredNodeId) + const showAllForwards = useNodeContextStore((state) => state.showAllForwards) + const edges = useFlowStore((state) => state.edges) + const hiddenForwardNodeIds = useFlowStore( + useShallow((state) => + state.nodes.filter((node) => isFrankNode(node) && node.data.hiddenForwards).map((node) => node.id), + ), + ) + + const dimmedHandleIndices = useMemo(() => { + const dimmed = new Set() + if (hiddenForwardNodeIds.length === 0) return dimmed + const hiddenSet = new Set(hiddenForwardNodeIds) + const isRevealed = (targetId: string) => + showAllForwards || + hoveredNodeId === targetId || + (hoveredNodeId !== null && edges.some((edge) => edge.source === hoveredNodeId && edge.target === targetId)) + + for (const edge of edges) { + if (edge.source !== properties.id || !hiddenSet.has(edge.target) || isRevealed(edge.target)) continue + + const handleIndex = Number(edge.sourceHandle) + if (!Number.isNaN(handleIndex)) dimmed.add(handleIndex) + } + + return dimmed + }, [edges, hiddenForwardNodeIds, hoveredNodeId, showAllForwards, properties.id]) + const allowedChildNames = useMemo( () => (xsdDoc ? new Set(getAllowedChildElementsForElement(xsdDoc, properties.data.subtype)) : null), [xsdDoc, properties.data.subtype], @@ -573,6 +603,7 @@ export default function FrankNode(properties: NodeProps) { onChangeType={(newType) => changeHandleType(handle.index, newType)} absolutePosition={{ x: properties.positionAbsoluteX, y: properties.positionAbsoluteY }} typesAllowed={frankElement?.forwards} + dimmed={dimmedHandleIndices.has(handle.index)} /> ))} {/* Only show the add handle button if there are available handle types that are not yet used on this node */} diff --git a/src/main/frontend/app/routes/studio/context/node-context.tsx b/src/main/frontend/app/routes/studio/context/node-context.tsx index 6797c4e3..42f59da3 100644 --- a/src/main/frontend/app/routes/studio/context/node-context.tsx +++ b/src/main/frontend/app/routes/studio/context/node-context.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useShortcut } from '~/hooks/use-shortcut' import useFlowStore, { isFrankNode } from '~/stores/flow-store' import Button from '~/components/inputs/button' +import Toggle from '~/components/inputs/toggle' import { useShallow } from 'zustand/react/shallow' import ContextInput from './context-input' import { findChildRecursive } from '~/stores/child-utilities' @@ -312,6 +313,15 @@ export default function NodeContext({ const currentName = inputValues['name'] ?? '' + const editedNode = !parentId && !childParentId ? nodes.find((node) => node.id === nodeId.toString()) : undefined + const canHideForwards = !!editedNode && isFrankNode(editedNode) && !isNewNode + const hiddenForwards = editedNode && isFrankNode(editedNode) ? Boolean(editedNode.data.hiddenForwards) : false + + const handleToggleHiddenForwards = (value: boolean) => { + useFlowStore.getState().setNodesHiddenForwards([nodeId.toString()], value) + void useNodeContextStore.getState().saveFlow?.() + } + const categorizedAttributes = (() => { if (!attributes) return [] @@ -347,6 +357,15 @@ export default function NodeContext({ <>
{currentName &&

{currentName}

} + {canHideForwards && ( +
+
+

Hide forwards

+

Hide this node's forwards to declutter the canvas

+
+ +
+ )}
{categorizedAttributes.map(([key, attribute, originalIndex]) => (
diff --git a/src/main/frontend/app/routes/studio/flow-to-xml-parser.ts b/src/main/frontend/app/routes/studio/flow-to-xml-parser.ts index aff7b539..635c2e55 100644 --- a/src/main/frontend/app/routes/studio/flow-to-xml-parser.ts +++ b/src/main/frontend/app/routes/studio/flow-to-xml-parser.ts @@ -19,6 +19,7 @@ type NodeData = { attributes?: Record sourceHandles?: [] children?: ChildNode[] + hiddenForwards?: boolean } function hasDataProperty(node: FlowNode): node is Extract { @@ -183,6 +184,7 @@ function generateXmlElement( 'flow:x': String(roundedX), 'flow:y': String(roundedY), ...(height === null ? {} : { 'flow:width': String(width), 'flow:height': String(height) }), + ...((node.data as NodeData).hiddenForwards ? { 'flow:hiddenForwards': 'true' } : {}), } const attrStr = Object.entries(allAttrs) .map(([k, v]) => `${k}="${escapeXml(v)}"`) diff --git a/src/main/frontend/app/routes/studio/xml-to-json-parser.ts b/src/main/frontend/app/routes/studio/xml-to-json-parser.ts index bd217ded..1c41f68f 100644 --- a/src/main/frontend/app/routes/studio/xml-to-json-parser.ts +++ b/src/main/frontend/app/routes/studio/xml-to-json-parser.ts @@ -559,7 +559,7 @@ function convertElementToNode(element: Element, idCounter: IdCounter, sourceHand const thisId = (idCounter.current++).toString() const { subtype, usedClassName } = translateElementFromOldToNewFormat(element) - const { attributes, name, x, y, width, height } = parseElementAttributes( + const { attributes, name, x, y, width, height, hiddenForwards } = parseElementAttributes( element.attributes, FlowConfig.NODE_DEFAULT_WIDTH, FlowConfig.NODE_MIN_HEIGHT, @@ -582,6 +582,7 @@ function convertElementToNode(element: Element, idCounter: IdCounter, sourceHand sourceHandles, attributes: Object.keys(attributes).length > 0 ? attributes : undefined, manuallyResized, + hiddenForwards: hiddenForwards || undefined, }, } } @@ -777,6 +778,7 @@ function parseElementAttributes( let y = 0 let width = defaultWidth let height: number | undefined = undefined + let hiddenForwards = false for (const attr of attrs) { const attrName = attr.name @@ -806,16 +808,22 @@ function parseElementAttributes( width = parseNumericAttribute(value, defaultWidth) continue } + if (attrName === 'flow:height') { height = parseNumericAttribute(value, defaultHeight) continue } + if (attrName === 'flow:hiddenForwards') { + hiddenForwards = value === 'true' + continue + } + // Store all other attributes attributes[attrName] = value } - return { attributes, name, x, y, width, height } + return { attributes, name, x, y, width, height, hiddenForwards } } type FrankEdge = { @@ -834,4 +842,5 @@ type ParsedAttributes = { y: number width: number height: number | undefined + hiddenForwards: boolean } diff --git a/src/main/frontend/app/stores/flow-store.ts b/src/main/frontend/app/stores/flow-store.ts index 64cab79a..e3660078 100644 --- a/src/main/frontend/app/stores/flow-store.ts +++ b/src/main/frontend/app/stores/flow-store.ts @@ -65,6 +65,7 @@ export type FlowState = { setGroupnodeColor: (nodeId: string, color: string) => void setNodeName: (nodeId: string, name: string, options?: { isNewNode?: boolean }) => void getNodeName: (nodeId: string) => string | null + setNodesHiddenForwards: (nodeIds: string[], hiddenForwards: boolean) => void addHandle: (nodeId: string, handle: { type: string; index: number }) => void updateHandle: (nodeId: string, handleIndex: number, newHandle: { type: string; index: number }) => void updateChild: (parentNodeId: string, updatedChild: ChildNode, options?: { isNewNode?: boolean }) => void @@ -516,6 +517,15 @@ const useFlowStore = create()( if (isFrankNode(node) || isExitNode(node)) return node.data.name ?? null return null }, + setNodesHiddenForwards: (nodeIds: string[], hiddenForwards: boolean) => { + get().saveToHistory() + const ids = new Set(nodeIds) + set({ + nodes: get().nodes.map((node) => + ids.has(node.id) && isFrankNode(node) ? { ...node, data: { ...node.data, hiddenForwards } } : node, + ), + }) + }, addHandle: (nodeId: string, handle: { type: string; index: number }) => { get().saveToHistory() set({ diff --git a/src/main/frontend/app/stores/node-context-store.ts b/src/main/frontend/app/stores/node-context-store.ts index 29652c0f..c0702efc 100644 --- a/src/main/frontend/app/stores/node-context-store.ts +++ b/src/main/frontend/app/stores/node-context-store.ts @@ -17,6 +17,8 @@ type NodeContextStore = { selectedStickyId: string | null selectedGroupId: string | null saveFlow: (() => Promise) | null + hoveredNodeId: string | null + showAllForwards: boolean setNodeId: (nodeId: number) => void setAttributes: (attributes?: Record) => void setIsEditing: (value: boolean) => void @@ -33,6 +35,9 @@ type NodeContextStore = { setIsMultiSelect: (value: boolean) => void setSelectedStickyId: (id: string | null) => void setSelectedGroupId: (id: string | null) => void + setHoveredNodeId: (id: string | null) => void + setShowAllForwards: (value: boolean) => void + toggleShowAllForwards: () => void } const useNodeContextStore = create((set) => ({ @@ -51,6 +56,8 @@ const useNodeContextStore = create((set) => ({ selectedStickyId: null, selectedGroupId: null, saveFlow: null, + hoveredNodeId: null, + showAllForwards: false, setNodeId: (nodeId) => set({ nodeId }), setAttributes: (attributes) => set({ attributes }), setIsEditing: (value) => set({ isEditing: value }), @@ -67,6 +74,9 @@ const useNodeContextStore = create((set) => ({ setIsMultiSelect: (isMultiSelect) => set({ isMultiSelect }), setSelectedStickyId: (selectedStickyId) => set({ selectedStickyId }), setSelectedGroupId: (selectedGroupId) => set({ selectedGroupId }), + setHoveredNodeId: (id) => set((state) => (state.hoveredNodeId === id ? state : { hoveredNodeId: id })), + setShowAllForwards: (showAllForwards) => set({ showAllForwards }), + toggleShowAllForwards: () => set((state) => ({ showAllForwards: !state.showAllForwards })), })) export default useNodeContextStore diff --git a/src/main/frontend/app/stores/shortcut-store.ts b/src/main/frontend/app/stores/shortcut-store.ts index 591c4d98..e175e98a 100644 --- a/src/main/frontend/app/stores/shortcut-store.ts +++ b/src/main/frontend/app/stores/shortcut-store.ts @@ -76,6 +76,7 @@ export const ALL_SHORTCUTS: Omit[] = [ { id: 'studio.cut', label: 'Cut Selection', scope: 'studio', key: 'x', modifiers: { cmdOrCtrl: true } }, { id: 'studio.group', label: 'Group Selection', scope: 'studio', key: 'g' }, { id: 'studio.ungroup', label: 'Ungroup Selection', scope: 'studio', key: 'g', modifiers: { shift: true } }, + { id: 'studio.hide', label: 'Hide / Show Forwards', scope: 'studio', key: 'h' }, { id: 'studio.save', label: 'Save Changes', scope: 'studio', key: 's', modifiers: { cmdOrCtrl: true } }, { id: 'studio.close-palette-card',