Improve selection of nodes

This commit is contained in:
Guilhem
2025-11-19 15:03:05 +00:00
parent 0410827411
commit d2c40d82b1
18 changed files with 138 additions and 132 deletions

View File

@@ -87,7 +87,7 @@
})
</script>
{#if selectionManager && selectionManager.selectedIds.length > 1}
{#if selectedId === 'multiple-selection'}
<FlowSelectionPanel {selectionManager} {noEditor} />
{:else if selectedId?.startsWith('settings')}
<FlowSettings {enableAi} {noEditor} />

View File

@@ -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)
}
}
</script>
@@ -25,16 +27,16 @@
{#snippet action()}
<Button
onClick={addGroupNote}
disabled={!noteEditorContext?.noteEditor || selectionManager.selectedIds.length === 0}
disabled={!noteEditorContext?.noteEditor || selectedNodes.length === 0}
startIcon={{ icon: StickyNote }}
>
Create group note
</Button>
{/snippet}
<div class="px-4">
<p class="text-xs text-secondary mb-4">{selectionManager.selectedIds.length} nodes selected</p>
<p class="text-xs text-secondary mb-4">{selectedNodes.length} nodes selected</p>
<div class="space-y-2 mb-4">
{#each selectionManager.selectedIds as nodeId}
{#each selectedNodes as nodeId}
<div class="text-sm px-2 py-1 bg-surface rounded border">
{nodeId}
</div>

View File

@@ -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 | undefined>('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),

View File

@@ -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
}))
})
}
})
</script>
{#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}
<NodeContextMenu
selectedNodeIds={selectionManager.selectedIds.filter(
(id) =>
!id.startsWith('Settings') && !id.startsWith('Trigger') && !id.startsWith('Result')
)}
>
<SelectionBoundingBox
selectedNodes={selectionManager.selectedIds}
allNodes={nodesWithOffset}
/>
<NodeContextMenu selectedNodeIds={[]}>
<SelectionBoundingBox allNodes={nodesWithOffset} />
</NodeContextMenu>
{/if}

View File

@@ -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()!}
<div
class={'absolute cursor-pointer bg-surface-selected/40 rounded-md'}
class={'absolute cursor-pointer bg-surface-selected/40 rounded-md pointer-events-none'}
style="
left: {currentBounds.x}px;
top: {currentBounds.y}px;

View File

@@ -1,5 +1,5 @@
<script lang="ts">
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
})
</script>

View File

@@ -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
})
}

View File

@@ -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()
</script>
<NodeWrapper offset={data.offset} enableSourceHandle enableTargetHandle>
@@ -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)
}}

View File

@@ -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()
</script>
<NodeWrapper offset={data.offset}>
@@ -17,7 +18,7 @@
<VirtualItem
label={data.label}
selectable
selected={false}
{selected}
on:select={() => {
setTimeout(() => data.eventHandlers.select(data.id))
}}

View File

@@ -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()
</script>
<NodeWrapper offset={data.offset}>
@@ -18,7 +19,7 @@
label={data.label}
preLabel={data.preLabel}
selectable
selected={false}
{selected}
on:select={() => {
setTimeout(() => data?.eventHandlers?.select(data.id))
}}

View File

@@ -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()
</script>
<NodeWrapper offset={data.offset}>
@@ -15,7 +16,7 @@
<VirtualItem
label={'Each event is processed'}
selectable={false}
selected={false}
{selected}
id={data.id}
hideId
on:select={(e) => {
@@ -26,7 +27,7 @@
<VirtualItem
label={'Collect result of each iteration'}
selectable={true}
selected={false}
{selected}
id={data.id}
on:select={(e) => {
setTimeout(() => data?.eventHandlers?.select(e.detail))

View File

@@ -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>('PropPickerContext')
const pickablePropertiesFiltered = propPickerContext?.pickablePropertiesFiltered
@@ -57,7 +58,7 @@
<VirtualItem
label={data.simplifiedTriggerView ? 'For each new event' : 'Do one iteration'}
selectable={false}
selected={false}
{selected}
id={data.id}
hideId
on:select={(e) => {

View File

@@ -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 | undefined>('FlowEditorContext') || {}
@@ -81,7 +79,7 @@
hideId={true}
label={inputLabel}
selectable
selected={selectionManager?.isNodeSelected('Input')}
{selected}
on:insert={(e) => {
setTimeout(() => data?.eventHandlers?.insert(e.detail))
}}

View File

@@ -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, '')
}}

View File

@@ -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()
</script>
<NodeWrapper enableSourceHandle={false}>
@@ -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))

View File

@@ -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))
</script>
<NodeWrapper>

View File

@@ -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()
</script>
<NodeWrapper offset={data.offset}>
@@ -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))
}}

View File

@@ -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<string[]>([])
#selectionMode = $state<'normal' | 'rect-select'>('normal')
#selectedNodesInGraph = $state<Node[]>([])
#manualSelectedId = $state<string | undefined>(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)
}
}
}
}