From d2c40d82b1db3701bbfb609d5704d9580c5a73e3 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 19 Nov 2025 15:03:05 +0000 Subject: [PATCH] Improve selection of nodes --- .../flows/content/FlowEditorPanel.svelte | 2 +- .../flows/content/FlowSelectionPanel.svelte | 12 +- .../lib/components/flows/map/MapItem.svelte | 11 +- .../lib/components/graph/FlowGraphV2.svelte | 33 +++-- .../graph/SelectionBoundingBox.svelte | 7 +- .../lib/components/graph/SelectionTool.svelte | 11 +- .../graph/renderers/nodes/AssetNode.svelte | 18 ++- .../renderers/nodes/BranchAllEndNode.svelte | 5 +- .../renderers/nodes/BranchAllStart.svelte | 5 +- .../renderers/nodes/BranchOneStart.svelte | 5 +- .../renderers/nodes/ForLoopEndNode.svelte | 7 +- .../renderers/nodes/ForLoopStartNode.svelte | 5 +- .../graph/renderers/nodes/InputNode.svelte | 8 +- .../graph/renderers/nodes/ModuleNode.svelte | 4 +- .../graph/renderers/nodes/ResultNode.svelte | 8 +- .../graph/renderers/nodes/TriggersNode.svelte | 7 +- .../renderers/nodes/branchOneEndNode.svelte | 5 +- .../components/graph/selectionUtils.svelte.ts | 117 +++++++++--------- 18 files changed, 138 insertions(+), 132 deletions(-) diff --git a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte index f0288e6533..57ebdf89e0 100644 --- a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte +++ b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte @@ -87,7 +87,7 @@ }) -{#if selectionManager && selectionManager.selectedIds.length > 1} +{#if selectedId === 'multiple-selection'} {:else if selectedId?.startsWith('settings')} diff --git a/frontend/src/lib/components/flows/content/FlowSelectionPanel.svelte b/frontend/src/lib/components/flows/content/FlowSelectionPanel.svelte index b14f090afc..ced83b2959 100644 --- a/frontend/src/lib/components/flows/content/FlowSelectionPanel.svelte +++ b/frontend/src/lib/components/flows/content/FlowSelectionPanel.svelte @@ -13,10 +13,12 @@ const noteEditorContext = getNoteEditorContext() + const selectedNodes = $derived(selectionManager.selectedNodesInGraph.map((node) => node.id)) + function addGroupNote() { - if (selectionManager.selectedIds.length > 0 && noteEditorContext?.noteEditor) { + if (selectedNodes.length > 0 && noteEditorContext?.noteEditor) { // Create the group note - noteEditorContext.noteEditor.createGroupNote(selectionManager.selectedIds) + noteEditorContext.noteEditor.createGroupNote(selectedNodes) } } @@ -25,16 +27,16 @@ {#snippet action()} {/snippet}
-

{selectionManager.selectedIds.length} nodes selected

+

{selectedNodes.length} nodes selected

- {#each selectionManager.selectedIds as nodeId} + {#each selectedNodes as nodeId}
{nodeId}
diff --git a/frontend/src/lib/components/flows/map/MapItem.svelte b/frontend/src/lib/components/flows/map/MapItem.svelte index be274a4dbd..b756010bdc 100644 --- a/frontend/src/lib/components/flows/map/MapItem.svelte +++ b/frontend/src/lib/components/flows/map/MapItem.svelte @@ -16,7 +16,6 @@ import { twMerge } from 'tailwind-merge' import type { FlowNodeState } from '$lib/components/graph' import type { AIModuleAction } from '$lib/components/copilot/chat/flow/core' - import { getGraphContext } from '$lib/components/graph/graphContext' interface Props { moduleId: string @@ -49,6 +48,7 @@ flowJob?: Job | undefined isOwner?: boolean maximizeSubflow?: () => void + selected: boolean } let { @@ -71,11 +71,10 @@ onEditInput, flowJob, isOwner = false, - maximizeSubflow + maximizeSubflow, + selected = false }: Props = $props() - const { selectionManager } = getGraphContext() - const { flowStore } = getContext('FlowEditorContext') || {} const dispatch = createEventDispatcher<{ @@ -86,9 +85,7 @@ }>() let itemProps = $derived({ - selected: - selectionManager?.getSelectedId() === mod.id || - (selectionManager && selectionManager.selectedIds.includes(mod.id)), + selected, retry: mod.retry?.constant != undefined || mod.retry?.exponential != undefined, earlyStop: mod.stop_after_if != undefined || mod.stop_after_all_iters_if != undefined, skip: Boolean(mod.skip_if), diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 319a00e3b4..aee811f99c 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -397,7 +397,7 @@ }, select: (modId) => { if (!notSelectable) { - selectionManager.selectId(modId) + console.log('dbg select', modId) onSelect?.(modId) } }, @@ -809,6 +809,23 @@ } const modifierKey = isMac() ? 'Meta' : 'Control' + + $inspect( + 'dbg selectionManager', + selectionManager.selectedNodesInGraph, + selectionManager.manualSelectedId + ) + + $effect(() => { + if (selectionManager.manualSelectedId) { + untrack(() => { + nodes = nodes.map((node) => ({ + ...node, + selected: node.id === selectionManager.manualSelectedId + })) + }) + } + }) {#if insertable} @@ -885,7 +902,7 @@ zoomOnDoubleClick={false} elevateNodesOnSelect={false} {proOptions} - multiSelectionKey={modifierKey} + multiSelectionKey={'Shift'} nodesDraggable={false} --background-color={false} > @@ -896,16 +913,8 @@ {/if} {#if multiSelectEnabled} - - !id.startsWith('Settings') && !id.startsWith('Trigger') && !id.startsWith('Result') - )} - > - + + {/if} diff --git a/frontend/src/lib/components/graph/SelectionBoundingBox.svelte b/frontend/src/lib/components/graph/SelectionBoundingBox.svelte index a945a86026..91825e7678 100644 --- a/frontend/src/lib/components/graph/SelectionBoundingBox.svelte +++ b/frontend/src/lib/components/graph/SelectionBoundingBox.svelte @@ -9,11 +9,10 @@ import { tick } from 'svelte' interface Props { - selectedNodes: string[] allNodes: Node[] } - let { selectedNodes, allNodes }: Props = $props() + let { allNodes }: Props = $props() const { flowToScreenPosition } = useSvelteFlow() @@ -22,6 +21,8 @@ // Get Graph context for clearFlowSelection function const graphContext = getGraphContext() + const selectedNodes = $derived(allNodes.filter((node) => node.selected).map((node) => node.id)) + function handleAddGroupNote() { if (selectedNodes.length > 0 && noteEditorContext?.noteEditor && graphContext) { // Create the group note first @@ -85,7 +86,7 @@ {#if bounds() && selectedNodes.length > 1} {@const currentBounds = bounds()!}
- import { useOnSelectionChange, useStore, type Node } from '@xyflow/svelte' + import { useOnSelectionChange, useStore } from '@xyflow/svelte' import type { SelectionManager } from './selectionUtils.svelte' interface Props { selectionManager: SelectionManager @@ -12,14 +12,7 @@ // Handle selection changes from SvelteFlow useOnSelectionChange(({ nodes: selectedNodes, edges: _selectedEdges }) => { - // Notes are already non-selectable, so no filtering needed - const selectedNodeIds = selectedNodes.map((node: Node) => node.id) - - // Only select nodes if multiple nodes are selected - // To avoid conflicting with the node-level click events - if (selectedNodeIds.length > 1) { - selectionManager.selectNodes(selectedNodeIds, false) - } + selectionManager.selectedNodesInGraph = selectedNodes }) diff --git a/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte index 9a8eda2a93..d782540dc2 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte @@ -133,14 +133,16 @@ source: n.id ?? '', target: n.parentId ?? '', type: 'empty', - data: { class: '!opacity-35 dark:!opacity-20' } + data: { class: '!opacity-35 dark:!opacity-20' }, + selectable: false })) const outputAssetEdges: Edge[] = outputAssetNodes?.map((n) => ({ id: `${n.id}-edge`, source: n.parentId ?? '', target: n.id ?? '', type: 'empty', - data: { class: '!opacity-35 dark:!opacity-20' } + data: { class: '!opacity-35 dark:!opacity-20' }, + selectable: false })) allAssetEdges.push(...(outputAssetEdges ?? []), ...(inputAssetEdges ?? [])) @@ -157,14 +159,16 @@ position: { x: MAX_ASSET_ROW_WIDTH - ASSETS_OVERFLOWED_NODE_WIDTH - 14, y: READ_ASSET_Y_OFFSET - } + }, + selectable: false } satisfies Node & AssetsOverflowedN) allAssetEdges.push({ id: `${node.id}-assets-overflowed-in-edge`, source: `${node.id}-assets-overflowed-in`, target: node.id, type: 'empty', - data: { class: '!opacity-35 dark:!opacity-20' } + data: { class: '!opacity-35 dark:!opacity-20' }, + selectable: false }) if (overflowedOutputAssets.length) allAssetNodes.push({ @@ -176,14 +180,16 @@ position: { x: MAX_ASSET_ROW_WIDTH - ASSETS_OVERFLOWED_NODE_WIDTH - 14, y: WRITE_ASSET_Y_OFFSET - } + }, + selectable: false } satisfies Node & AssetsOverflowedN) allAssetEdges.push({ id: `${node.id}-assets-overflowed-out-edge`, source: node.id, target: `${node.id}-assets-overflowed-out`, type: 'empty', - data: { class: '!opacity-35 dark:!opacity-25' } + data: { class: '!opacity-35 dark:!opacity-25' }, + selectable: false }) } diff --git a/frontend/src/lib/components/graph/renderers/nodes/BranchAllEndNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/BranchAllEndNode.svelte index 230d04d4b3..913bceb194 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/BranchAllEndNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/BranchAllEndNode.svelte @@ -4,9 +4,10 @@ import type { BranchAllEndN } from '../../graphBuilder.svelte' interface Props { data: BranchAllEndN['data'] + selected: boolean } - let { data }: Props = $props() + let { data, selected }: Props = $props() @@ -15,7 +16,7 @@ label={'Collect result from all branches'} id={data.id} selectable={true} - selected={false} + {selected} on:select={(e) => { data?.eventHandlers?.select(e.detail) }} diff --git a/frontend/src/lib/components/graph/renderers/nodes/BranchAllStart.svelte b/frontend/src/lib/components/graph/renderers/nodes/BranchAllStart.svelte index b59805a871..9af02feea6 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/BranchAllStart.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/BranchAllStart.svelte @@ -7,9 +7,10 @@ import type { BranchAllStartN } from '../../graphBuilder.svelte' interface Props { data: BranchAllStartN['data'] + selected: boolean } - let { data }: Props = $props() + let { data, selected }: Props = $props() @@ -17,7 +18,7 @@ { setTimeout(() => data.eventHandlers.select(data.id)) }} diff --git a/frontend/src/lib/components/graph/renderers/nodes/BranchOneStart.svelte b/frontend/src/lib/components/graph/renderers/nodes/BranchOneStart.svelte index c5c259b4a8..b16f355bed 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/BranchOneStart.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/BranchOneStart.svelte @@ -7,9 +7,10 @@ import type { BranchOneStartN } from '../../graphBuilder.svelte' interface Props { data: BranchOneStartN['data'] + selected: boolean } - let { data }: Props = $props() + let { data, selected }: Props = $props() @@ -18,7 +19,7 @@ label={data.label} preLabel={data.preLabel} selectable - selected={false} + {selected} on:select={() => { setTimeout(() => data?.eventHandlers?.select(data.id)) }} diff --git a/frontend/src/lib/components/graph/renderers/nodes/ForLoopEndNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/ForLoopEndNode.svelte index 379d75a6ba..bc10450b32 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/ForLoopEndNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/ForLoopEndNode.svelte @@ -4,9 +4,10 @@ import type { ForLoopEndN } from '../../graphBuilder.svelte' interface Props { data: ForLoopEndN['data'] + selected: boolean } - let { data }: Props = $props() + let { data, selected }: Props = $props() @@ -15,7 +16,7 @@ { @@ -26,7 +27,7 @@ { setTimeout(() => data?.eventHandlers?.select(e.detail)) diff --git a/frontend/src/lib/components/graph/renderers/nodes/ForLoopStartNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/ForLoopStartNode.svelte index 70b900957e..c428faa6a2 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/ForLoopStartNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/ForLoopStartNode.svelte @@ -7,9 +7,10 @@ import type { ForLoopStartN } from '../../graphBuilder.svelte' interface Props { data: ForLoopStartN['data'] + selected: boolean } - let { data }: Props = $props() + let { data, selected }: Props = $props() const propPickerContext = getContext('PropPickerContext') const pickablePropertiesFiltered = propPickerContext?.pickablePropertiesFiltered @@ -57,7 +58,7 @@ { diff --git a/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte index dc76c013b6..ef5023b2bb 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte @@ -11,15 +11,13 @@ import type { FlowEditorContext } from '$lib/components/flows/types' import { MessageSquare, DiffIcon } from 'lucide-svelte' import { Button } from '$lib/components/common' - import { getGraphContext } from '../../graphContext' interface Props { data: InputN['data'] + selected: boolean } - let { data }: Props = $props() - - const { selectionManager } = getGraphContext() + let { data, selected }: Props = $props() const { previewArgs, flowStore } = getContext('FlowEditorContext') || {} @@ -81,7 +79,7 @@ hideId={true} label={inputLabel} selectable - selected={selectionManager?.isNodeSelected('Input')} + {selected} on:insert={(e) => { setTimeout(() => data?.eventHandlers?.insert(e.detail)) }} diff --git a/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte index 99086cb9b2..76f0525877 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte @@ -10,9 +10,10 @@ interface Props { data: ModuleN['data'] + selected: boolean } - let { data }: Props = $props() + let { data, selected }: Props = $props() // Get NoteEditor context for group note creation const noteEditorContext = getNoteEditorContext() @@ -71,6 +72,7 @@ duration_ms={state?.duration_ms} retries={state?.retries} {flowJobs} + {selected} on:delete={(e) => { data.eventHandlers.delete(e.detail, '') }} diff --git a/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte index b12c3369dc..e35bb0bafd 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte @@ -2,15 +2,13 @@ import VirtualItem from '$lib/components/flows/map/VirtualItem.svelte' import NodeWrapper from './NodeWrapper.svelte' import type { ResultN } from '../../graphBuilder.svelte' - import { getGraphContext } from '../../graphContext' interface Props { data: ResultN['data'] + selected: boolean } - let { data }: Props = $props() - - const { selectionManager } = getGraphContext() + let { data, selected }: Props = $props() @@ -19,7 +17,7 @@ id={'Result'} label={'Result'} selectable={true} - selected={selectionManager?.getSelectedId() === 'Result'} + {selected} hideId={true} on:select={(e) => { setTimeout(() => data?.eventHandlers?.select(e.detail)) diff --git a/frontend/src/lib/components/graph/renderers/nodes/TriggersNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/TriggersNode.svelte index 58f95d4b33..70d91fba14 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/TriggersNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/TriggersNode.svelte @@ -26,9 +26,10 @@ disableAi: boolean simplifiableFlow: SimplifiableFlow } + selected: boolean } - let { data }: Props = $props() + let { data, selected }: Props = $props() const { selectionManager } = getGraphContext() @@ -48,9 +49,7 @@ } } - let colorClasses = $derived( - getNodeColorClasses('_VirtualItem', selectionManager?.isNodeSelected('Trigger')) - ) + let colorClasses = $derived(getNodeColorClasses('_VirtualItem', selected)) diff --git a/frontend/src/lib/components/graph/renderers/nodes/branchOneEndNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/branchOneEndNode.svelte index 88a4985b8c..e9eaeb1316 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/branchOneEndNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/branchOneEndNode.svelte @@ -4,9 +4,10 @@ import type { BranchOneEndN } from '../../graphBuilder.svelte' interface Props { data: BranchOneEndN['data'] + selected: boolean } - let { data }: Props = $props() + let { data, selected }: Props = $props() @@ -15,7 +16,7 @@ label={'Collect result from chosen branch'} id={data.id} selectable={true} - selected={false} + {selected} on:select={(e) => { setTimeout(() => data?.eventHandlers?.select(e.detail)) }} diff --git a/frontend/src/lib/components/graph/selectionUtils.svelte.ts b/frontend/src/lib/components/graph/selectionUtils.svelte.ts index e1e1ba512a..9a281d6bec 100644 --- a/frontend/src/lib/components/graph/selectionUtils.svelte.ts +++ b/frontend/src/lib/components/graph/selectionUtils.svelte.ts @@ -1,20 +1,69 @@ import type { Node } from '@xyflow/svelte' +const MULTIPLE_SELECTION_ID = 'multiple-selection' +const SETTINGS_ID = 'settings' + +/** + * + * @param node - The node to get the module id from + * @returns The module id from the node + */ +function getModuleIdFromNode(node: Node): string | undefined { + if (node.type === 'branchOneEnd') { + return /^(.*)-end$/.exec(node.id)?.[1] + } else if (node.type === 'branchAllEnd') { + return /^(.*)-end$/.exec(node.id)?.[1] + } else if (node.type === 'forLoopEnd') { + return /^(.*)-end$/.exec(node.id)?.[1] + } else if (node.type === 'whileLoopEnd') { + return /^(.*)-end$/.exec(node.id)?.[1] + } else if (node.type === 'subflowBound') { + return /^(.*)-end$/.exec(node.id)?.[1] + } + return undefined +} + export class SelectionManager { - #selectedIds = $state([]) #selectionMode = $state<'normal' | 'rect-select'>('normal') + #selectedNodesInGraph = $state([]) + #manualSelectedId = $state(undefined) constructor() {} selectId(id: string) { - if (this.#selectedIds.length === 1 && this.#selectedIds[0] === id) { - return - } - this.#selectedIds = [id] + // If not in the graph, set the selected id outside the graph + this.#manualSelectedId = id } getSelectedId(): string { - return this.#selectedIds[0] || 'settings' + if (this.#manualSelectedId !== undefined) { + return this.#manualSelectedId + } + if (this.#selectedNodesInGraph.length === 1) { + const selectedNode = this.#selectedNodesInGraph[0] + const moduleId = getModuleIdFromNode(selectedNode) + if (moduleId) { + return moduleId + } + return selectedNode.id + } else if (this.#selectedNodesInGraph.length > 1) { + return MULTIPLE_SELECTION_ID + } else { + return SETTINGS_ID + } + } + + get selectedNodesInGraph() { + return this.#selectedNodesInGraph + } + + get manualSelectedId() { + return this.#manualSelectedId + } + + set selectedNodesInGraph(nodes: Node[]) { + this.#manualSelectedId = undefined + this.#selectedNodesInGraph = nodes } get mode() { @@ -25,56 +74,9 @@ export class SelectionManager { this.#selectionMode = mode } - get selectedIds() { - if (this.#selectedIds.length === 0) { - return ['settings'] - } - return [...this.#selectedIds] - } - - // Select nodes with optional hierarchical selection - selectNodes(nodeIds: string[], addToExisting = false) { - // Guard against empty nodeIds or uninitialized state - if (!nodeIds || nodeIds.length === 0) { - if (!addToExisting) { - this.clearSelection() - } - return - } - - const newSelection = addToExisting ? [...this.#selectedIds, ...nodeIds] : nodeIds - - // If the new selection is the same as the current selection, do nothing - if (JSON.stringify(newSelection) === JSON.stringify($state.snapshot(this.#selectedIds))) { - return - } - - this.#selectedIds = newSelection - } - // Clear all selections clearSelection() { - this.#selectedIds = ['settings'] - } - - // Check if a node is selected - isNodeSelected(nodeId: string): boolean { - return this.#selectedIds.includes(nodeId) - } - - // Get selected node count - get selectedCount(): number { - return this.#selectedIds.length - } - - // Check if multiple nodes are selected - get hasMultipleSelection(): boolean { - return this.selectedCount > 1 - } - - // Get all selected node IDs - get selectedNodeIds(): string[] { - return [...this.#selectedIds] + this.#manualSelectedId = undefined } // Handle keyboard shortcuts @@ -82,13 +84,6 @@ export class SelectionManager { if (event.key === 'Escape') { // Escape key clears selection regardless of mode this.clearSelection() - } else if ((event.ctrlKey || event.metaKey) && event.key === 'a') { - event.preventDefault() - // Select all visible nodes (exclude note nodes) - if (nodes) { - const allNodeIds = nodes.filter((node) => node.type !== 'note').map((node) => node.id) - this.selectNodes(allNodeIds) - } } } }