Introduce node multiselect

This commit is contained in:
Guilhem
2025-11-06 18:11:20 +01:00
parent 4b451f561e
commit c9fd1934cd
43 changed files with 793 additions and 240 deletions
+8 -6
View File
@@ -31,6 +31,7 @@
import type { FlowState } from './flows/flowState'
import { initHistory } from '$lib/history.svelte'
import type { FlowEditorContext, FlowInput, FlowInputEditorState } from './flows/types'
import { SelectionManager } from './graph/selectionUtils.svelte'
import { dfs } from './flows/dfs'
import { loadSchemaFromModule } from './flows/flowInfers'
import { CornerDownLeft, Play } from 'lucide-svelte'
@@ -475,7 +476,7 @@
let ids = dfs(flowStore.val.value.modules ?? [], (m) => m.id)
flowStateStore.val = Object.fromEntries(ids.map((k) => [k, {}]))
} catch (e) {}
inferModuleArgs($selectedIdStore)
inferModuleArgs(selectionManager.getSelectedId()!)
}
} catch (e) {
console.error('issue setting new flowstore', e)
@@ -489,7 +490,8 @@
const moving = writable<{ id: string } | undefined>(undefined)
const history = initHistory(flowStore.val)
const stepsInputArgs = new StepsInputArgs()
const selectedIdStore = writable('settings-metadata')
const selectionManager = new SelectionManager()
selectionManager.selectId('settings-metadata')
const triggersCount = writable<TriggersCount | undefined>(undefined)
const modulesTestStates = new ModulesTestStates((moduleId) => {
// console.log('FOO')
@@ -508,7 +510,7 @@
let pathStore = writable('')
let initialPathStore = writable('')
setContext<FlowEditorContext>('FlowEditorContext', {
selectedId: selectedIdStore,
selectionManager,
previewArgs: previewArgsStore,
scriptEditorDrawer,
moving,
@@ -618,7 +620,7 @@
flowStore.val && untrack(() => updateFlow(flowStore.val))
})
$effect(() => {
$selectedIdStore && untrack(() => inferModuleArgs($selectedIdStore))
selectionManager.getSelectedId() && untrack(() => inferModuleArgs(selectionManager.getSelectedId()!))
})
let localModuleStates: Record<string, GraphModuleState> = $state({})
@@ -640,7 +642,7 @@
job.success &&
flowPreviewButtons?.getPreviewMode() === 'whole'
) {
if (flowModuleSchemaMap?.isNodeVisible('result') && $selectedIdStore !== 'Result') {
if (flowModuleSchemaMap?.isNodeVisible('result') && selectionManager.getSelectedId() !== 'Result') {
outputPickerOpenFns['Result']?.()
}
} else {
@@ -846,7 +848,7 @@
on:applyArgs={(ev) => {
if (ev.detail.kind === 'preprocessor') {
stepsInputArgs.setStepArgs('preprocessor', ev.detail.args ?? {})
$selectedIdStore = 'preprocessor'
selectionManager.selectId('preprocessor')
} else {
previewArgsStore.val = ev.detail.args ?? {}
flowPreviewButtons?.openPreview()
+24 -17
View File
@@ -43,6 +43,7 @@
import FlowImportExportMenu from './flows/header/FlowImportExportMenu.svelte'
import FlowPreviewButtons from './flows/header/FlowPreviewButtons.svelte'
import type { FlowEditorContext, FlowInput, FlowInputEditorState } from './flows/types'
import { SelectionManager } from './graph/selectionUtils.svelte'
import { cleanInputs } from './flows/utils'
import {
Calendar,
@@ -338,11 +339,11 @@
let savedAtNewPath = false
if (newFlow) {
onSaveInitial?.({ path: $pathStore, id: getSelectedId() })
onSaveInitial?.({ path: $pathStore, id: getSelectedId() ?? 'settings' })
} else if (savedFlow?.draft_only && $pathStore !== initialPath) {
savedAtNewPath = true
initialPath = $pathStore
onSaveDraftOnlyAtNewPath?.({ path: $pathStore, selectedId: getSelectedId() })
onSaveDraftOnlyAtNewPath?.({ path: $pathStore, selectedId: getSelectedId() ?? 'settings' })
// this is so we can use the flow builder outside of sveltekit
}
onSaveDraft?.({ path: $pathStore, savedAtNewPath, newFlow })
@@ -561,7 +562,7 @@
encodeState({
flow: flowStore.val,
path: $pathStore,
selectedId: $selectedIdStore,
selectedId: selectionManager.getSelectedId(),
draft_triggers: triggersState.getDraftTriggersSnapshot(),
selected_trigger: triggersState.getSelectedTriggerSnapshot(),
loadedFromHistory: {
@@ -576,10 +577,16 @@
}, 500)
}
const selectedIdStore = writable<string>(selectedId ?? 'settings-metadata')
const selectionManager = new SelectionManager()
// Initialize with selected id if provided
if (selectedId) {
selectionManager.selectId(selectedId)
} else {
selectionManager.selectId('settings-metadata')
}
export function getSelectedId() {
return $selectedIdStore
return selectionManager.getSelectedId()
}
const previewArgsStore = $state({ val: initialArgs })
@@ -598,7 +605,7 @@
const stepsInputArgs = new StepsInputArgs()
function select(selectedId: string) {
selectedIdStore.set(selectedId)
selectionManager.selectId(selectedId)
}
let insertButtonOpen = writable<boolean>(false)
@@ -607,7 +614,7 @@
let flowEditor: FlowEditor | undefined = $state(undefined)
setContext<FlowEditorContext>('FlowEditorContext', {
selectedId: selectedIdStore,
selectionManager,
currentEditor: writable(undefined),
previewArgs: previewArgsStore,
scriptEditorDrawer,
@@ -695,7 +702,7 @@
case 'z':
if (event.ctrlKey || event.metaKey) {
flowStore.val = undo(history, flowStore.val)
$selectedIdStore = 'Input'
selectionManager.selectId('Input')
event.preventDefault()
}
break
@@ -708,9 +715,9 @@
case 'ArrowDown': {
if (!$insertButtonOpen && !flowPreviewButtons?.getPreviewOpen()) {
let ids = generateIds()
let idx = ids.indexOf($selectedIdStore)
let idx = ids.indexOf(selectionManager.getSelectedId()!)
if (idx > -1 && idx < ids.length - 1) {
$selectedIdStore = ids[idx + 1]
selectionManager.selectId(ids[idx + 1])
event.preventDefault()
}
}
@@ -719,9 +726,9 @@
case 'ArrowUp': {
if (!$insertButtonOpen && !flowPreviewButtons?.getPreviewOpen()) {
let ids = generateIds()
let idx = ids.indexOf($selectedIdStore)
let idx = ids.indexOf(selectionManager.getSelectedId()!)
if (idx > 0 && idx < ids.length) {
$selectedIdStore = ids[idx - 1]
selectionManager.selectId(ids[idx - 1])
event.preventDefault()
}
}
@@ -868,7 +875,7 @@
setContext('customUi', customUi)
})
$effect.pre(() => {
if (flowStore.val || $selectedIdStore) {
if (flowStore.val || selectionManager.getSelectedId()) {
readFieldsRecursively(flowStore.val)
untrack(() => saveSessionDraft())
}
@@ -932,7 +939,7 @@
job.success &&
flowPreviewButtons?.getPreviewMode() === 'whole'
) {
if (flowEditor?.isNodeVisible('result') && $selectedIdStore !== 'Result') {
if (flowEditor?.isNodeVisible('result') && selectionManager.getSelectedId() !== 'Result') {
outputPickerOpenFns['Result']?.()
}
} else {
@@ -1026,7 +1033,7 @@
}
}
$selectedIdStore = 'Input'
selectionManager.selectId('Input')
}}
on:redo={() => {
flowStore.val = redo(history)
@@ -1190,7 +1197,7 @@
on:applyArgs={(ev) => {
if (ev.detail.kind === 'preprocessor') {
stepsInputArgs.setStepArgs('preprocessor', ev.detail.args ?? {})
$selectedIdStore = 'preprocessor'
selectionManager.selectId('preprocessor')
}
}}
on:testWithArgs={(e) => {
@@ -1203,7 +1210,7 @@
{savedFlow}
onDeployTrigger={handleDeployTrigger}
onEditInput={(moduleId, key) => {
selectedIdStore.set(moduleId)
selectionManager.selectId(moduleId)
// Use new prop-based system
forceTestTab[moduleId] = true
highlightArg[moduleId] = key
@@ -46,7 +46,7 @@
failureModule={flow?.value?.failure_module}
preprocessorModule={flow?.value?.preprocessor_module}
onSelect={(nodeId) => {
if (nodeId === 'triggers') {
if (nodeId === 'Trigger') {
dispatch('triggerDetail')
return
} else if (nodeId === 'failure') {
@@ -100,7 +100,7 @@
}
const {
selectedId,
selectionManager,
previewArgs,
flowStateStore,
flowStore,
@@ -126,7 +126,7 @@
} else {
const flow = previewFlow ?? stateSnapshot(flowStore).val
const idOrders = dfs(flow.value.modules, (x) => x.id)
let upToIndex = idOrders.indexOf(upToId ?? $selectedId)
let upToIndex = idOrders.indexOf(upToId ?? selectionManager.getSelectedId() ?? '')
if (upToIndex != -1) {
flow.value.modules = sliceModules(flow.value.modules, upToIndex, idOrders)
@@ -430,7 +430,7 @@
{#if previewMode == 'upTo'}
Test up to
<Badge baseClass="ml-1" color="indigo">
{$selectedId}
{selectionManager.getSelectedId()}
</Badge>
{:else}
Test flow
@@ -39,7 +39,6 @@
import type { FlowGraphAssetContext } from './flows/types'
import { createState } from '$lib/svelte5Utils.svelte'
import JobLoader from './JobLoader.svelte'
import { writable } from 'svelte/store'
import {
AI_TOOL_CALL_PREFIX,
AI_TOOL_MESSAGE_PREFIX,
@@ -48,6 +47,7 @@
} from './graph/renderers/nodes/AIToolNode.svelte'
import JobAssetsViewer from './assets/JobAssetsViewer.svelte'
import McpToolCallDetails from './McpToolCallDetails.svelte'
import { SelectionManager } from './graph/selectionUtils.svelte'
let {
flowState: flowStateStore,
@@ -232,7 +232,7 @@
let expandedSubflows: Record<string, FlowModule[]> = $state({})
let selectedId = writable<string | undefined>(selectedNode)
let selectionManager = new SelectionManager()
function onFlowModuleId() {
let modId = flowJobIds?.moduleId
@@ -1730,7 +1730,7 @@
{/each}
</div>
<FlowGraphV2
{selectedId}
{selectionManager}
triggerNode={true}
download={!hideDownloadInGraph}
minHeight={wrapperHeight}
@@ -35,7 +35,7 @@
)
let abortController = new AbortController()
const { flowStore, selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
const { flowStore, selectionManager } = getContext<FlowEditorContext>('FlowEditorContext')
async function generateIteratorExpr() {
if (generatedContent.length > 0 || loading) {
@@ -45,7 +45,7 @@
loading = true
const flow: Flow = JSON.parse(JSON.stringify(flowStore.val))
const idOrders = dfs(flow.value.modules, (x) => x.id)
const upToIndex = idOrders.indexOf($selectedId)
const upToIndex = idOrders.indexOf(selectionManager.getSelectedId()!)
if (upToIndex === -1) {
throw new Error('Could not find the selected id in the flow')
}
@@ -60,7 +60,7 @@
flow_input: pickableProperties?.flow_input
}
const user = `I'm building a workflow which is a DAG of script steps.
The current step is ${$selectedId} and represents a for-loop. You can find the details of all the steps below:
The current step is ${selectionManager.getSelectedId()!} and represents a for-loop. You can find the details of all the steps below:
${flowDetails}
Determine the iterator expression to pass either from the previous results or the flow inputs. Here's a summary of the available data:
<available>
@@ -29,7 +29,7 @@
})
let abortController = $state(new AbortController())
const { flowStore, selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
const { flowStore, selectionManager } = getContext<FlowEditorContext>('FlowEditorContext')
const dispatch = createEventDispatcher()
@@ -38,7 +38,7 @@
loading = true
const flow: Flow = JSON.parse(JSON.stringify(flowStore.val))
const idOrders = dfs(flow.value.modules, (x) => x.id)
const upToIndex = idOrders.indexOf($selectedId)
const upToIndex = idOrders.indexOf(selectionManager.getSelectedId()!)
if (upToIndex === -1) {
throw new Error('Could not find the selected id in the flow')
}
@@ -53,7 +53,7 @@
flow_input: pickableProperties?.flow_input
}
const user = `I'm building a workflow which is a DAG of script steps.
The current step is ${$selectedId} and is a branching step (if-else).
The current step is ${selectionManager.getSelectedId()!} and is a branching step (if-else).
The user wants to generate a predicate for the branching condition.
Here's the user's request: ${instructions}
You can find the details of all the steps below:
@@ -54,7 +54,7 @@
let abortController = new AbortController()
let newFlowInput = $state('')
const { flowStore, selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
const { flowStore, selectionManager } = getContext<FlowEditorContext>('FlowEditorContext')
const { stepInputsLoading, generatedExprs } =
getContext<FlowCopilotContext | undefined>('FlowCopilotContext') || {}
@@ -86,7 +86,7 @@
loading = true
const flow: Flow = JSON.parse(JSON.stringify(flowStore.val))
const idOrders = dfs(flow.value.modules, (x) => x.id)
const upToIndex = idOrders.indexOf($selectedId)
const upToIndex = idOrders.indexOf(selectionManager.getSelectedId()!)
if (upToIndex === -1) {
throw new Error('Could not find the selected id in the flow')
}
@@ -102,7 +102,7 @@
}
const isInsideLoop = availableData.flow_input && 'iter' in availableData.flow_input
const user = `I'm building a workflow which is a DAG of script steps.
The current step is ${$selectedId}, you can find the details for the step and previous ones below:
The current step is ${selectionManager.getSelectedId()!}, you can find the details for the step and previous ones below:
${flowDetails}
Determine for the input "${argName}", what to pass either from the previous results or the flow inputs.
All possibles inputs either start with results. or flow_input. and are followed by the key of the input.
@@ -30,7 +30,7 @@
let { pickableProperties = undefined, argNames = [], schema = undefined }: Props = $props()
const { flowStore, selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
const { flowStore, selectionManager } = getContext<FlowEditorContext>('FlowEditorContext')
const { exprsToSet, stepInputsLoading, generatedExprs } =
getContext<FlowCopilotContext | undefined>('FlowCopilotContext') || {}
@@ -49,7 +49,7 @@
stepInputsLoading?.set(true)
const flow: Flow = JSON.parse(JSON.stringify(flowStore.val))
const idOrders = dfs(flow.value.modules, (x) => x.id)
const upToIndex = idOrders.indexOf($selectedId)
const upToIndex = idOrders.indexOf(selectionManager.getSelectedId()!)
if (upToIndex === -1) {
throw new Error('Could not find the selected id in the flow')
}
@@ -65,7 +65,7 @@
}
const isInsideLoop = availableData.flow_input && 'iter' in availableData.flow_input
const user = `I'm building a workflow which is a DAG of script steps.
The current step is ${$selectedId}, you can find the details for the step and previous ones below:
The current step is ${selectionManager.getSelectedId()!}, you can find the details for the step and previous ones below:
${flowDetails}
Determine for all the inputs "${argNames.join(
@@ -872,7 +872,7 @@ class AIChatManager {
}
listenForSelectedIdChanges = (
selectedId: string,
selectedId: string | undefined,
flowStore: ExtendedOpenFlow,
flowStateStore: FlowState,
currentEditor: CurrentEditor
@@ -25,7 +25,7 @@
flowModuleSchemaMap: FlowModuleSchemaMap | undefined
} = $props()
const { flowStore, flowStateStore, selectedId, currentEditor } =
const { flowStore, flowStateStore, selectionManager, currentEditor } =
getContext<FlowEditorContext>('FlowEditorContext')
const { exprsToSet } = getContext<FlowCopilotContext | undefined>('FlowCopilotContext') ?? {}
@@ -84,7 +84,7 @@
const flow = $state.snapshot(flowStore).val
return {
flow,
selectedId: $selectedId
selectedId: selectionManager.getSelectedId()!
}
},
// flow apply/reject
@@ -382,7 +382,7 @@
value: match[2].trim()
}))
if (id === $selectedId) {
if (id === selectionManager.getSelectedId()!) {
exprsToSet?.set({})
const argsToUpdate = {}
for (const { input, value } of parsedInputs) {
@@ -421,7 +421,7 @@
setModuleStatus('Input', 'modified')
},
selectStep: (id) => {
$selectedId = id
selectionManager.selectId(id)
},
getStepCode: (id) => {
const module = getModule(id)
@@ -611,7 +611,7 @@
$effect(() => {
const cleanup = aiChatManager.listenForSelectedIdChanges(
$selectedId,
selectionManager.getSelectedId(),
flowStore.val,
flowStateStore.val,
$currentEditor
@@ -626,21 +626,21 @@
// Automatically show revert review when selecting a rawscript module with pending changes
$effect(() => {
const selectedId = selectionManager.getSelectedId()
if (
$currentEditor?.type === 'script' &&
$selectedId &&
affectedModules[$selectedId] &&
selectedId &&
affectedModules[selectedId] &&
$currentEditor.editor.getAiChatEditorHandler()
) {
const moduleLastSnapshot = getModule($selectedId, lastSnapshot)
const moduleLastSnapshot = getModule(selectedId, lastSnapshot)
const content =
moduleLastSnapshot?.value.type === 'rawscript' ? moduleLastSnapshot.value.content : ''
if (content.length > 0) {
untrack(() =>
$currentEditor.editor.reviewAppliedCode(content, {
onFinishedReview: () => {
const id = $selectedId
flowHelpers.acceptModuleAction(id)
flowHelpers.acceptModuleAction(selectedId)
$currentEditor.hideDiffMode()
}
})
@@ -55,7 +55,7 @@
}: Props = $props()
const {
selectedId,
selectionManager,
flowStore,
flowStateStore,
flowInputsStore,
@@ -84,14 +84,26 @@
})
</script>
{#if $selectedId?.startsWith('settings')}
{#if selectionManager && selectionManager.selectedIds.length > 1}
<div class="p-4">
<h3 class="text-lg font-semibold mb-2">Multiple Selection</h3>
<p class="text-sm text-secondary mb-4">{selectionManager.selectedIds.length} nodes selected</p>
<div class="space-y-2">
{#each selectionManager.selectedIds as nodeId}
<div class="text-sm px-2 py-1 bg-surface rounded border">
{nodeId}
</div>
{/each}
</div>
</div>
{:else if selectionManager.getSelectedId()?.startsWith('settings')}
<FlowSettings {enableAi} {noEditor} />
{:else if $selectedId === 'Input'}
{:else if selectionManager.getSelectedId() === 'Input'}
<FlowInput
{noEditor}
disabled={disabledFlowInputs}
on:openTriggers={(ev) => {
$selectedId = 'triggers'
selectionManager.selectId('Trigger')
handleSelectTriggerFromKind(triggersState, triggersCount, savedFlow?.path, ev.detail.kind)
showCaptureHint.set(true)
}}
@@ -99,22 +111,22 @@
{onTestFlow}
{previewOpen}
/>
{:else if $selectedId === 'Result'}
{:else if selectionManager.getSelectedId() === 'Result'}
<FlowResult {noEditor} {job} {isOwner} {suspendStatus} {onOpenDetails} />
{:else if $selectedId === 'constants'}
{:else if selectionManager.getSelectedId() === 'constants'}
<FlowConstants {noEditor} />
{:else if $selectedId === 'failure'}
{:else if selectionManager.getSelectedId() === 'failure'}
<FlowFailureModule {noEditor} savedModule={savedFlow?.value.failure_module} />
{:else if $selectedId === 'preprocessor'}
{:else if selectionManager.getSelectedId() === 'preprocessor'}
<FlowPreprocessorModule {noEditor} savedModule={savedFlow?.value.preprocessor_module} />
{:else if $selectedId === 'triggers'}
{:else if selectionManager.getSelectedId() === 'Trigger'}
<TriggersEditor
on:applyArgs
on:addPreprocessor={async () => {
await insertNewPreprocessorModule(flowStore, flowStateStore, {
language: 'bun'
})
$selectedId = 'preprocessor'
selectionManager.selectId('preprocessor')
}}
on:updateSchema={(e) => {
const { payloadData, redirect } = e.detail
@@ -122,7 +134,7 @@
previewArgs.val = JSON.parse(JSON.stringify(payloadData))
}
if (redirect) {
$selectedId = 'Input'
selectionManager.selectId('Input')
$flowInputEditorState.selectedTab = 'captures'
$flowInputEditorState.payloadData = payloadData
}
@@ -141,7 +153,7 @@
schema={flowStore.val.schema}
{onDeployTrigger}
/>
{:else if $selectedId.startsWith('subflow:')}
{:else if selectionManager.getSelectedId()?.startsWith('subflow:')}
<div class="p-4"
>Selected step is witin an expanded subflow and is not directly editable in the flow editor</div
>
@@ -150,7 +162,7 @@
{#if dup}
<div class="text-red-600 text-xl p-2">There are duplicate modules in the flow at id: {dup}</div>
{:else}
{#key $selectedId}
{#key selectionManager.getSelectedId()}
{#each flowStore.val.value.modules as flowModule, index (flowModule.id ?? index)}
<FlowModuleWrapper
{noEditor}
@@ -59,7 +59,7 @@
import { DynamicInput } from '$lib/utils'
const {
selectedId,
selectionManager,
currentEditor,
previewArgs,
flowStateStore,
@@ -215,7 +215,7 @@
let stepHistoryLoader = getStepHistoryLoaderContext()
function onSelectedIdChange() {
if (!flowStateStore?.val?.[$selectedId]?.schema && flowModule) {
if (!flowStateStore?.val?.[selectionManager.getSelectedId()!]?.schema && flowModule) {
reload(flowModule)
}
}
@@ -252,7 +252,7 @@
)
$effect.pre(() => {
$selectedId && untrack(() => onSelectedIdChange())
selectionManager.getSelectedId() && untrack(() => onSelectedIdChange())
})
let parentLoop = $derived(
flowStore.val && flowModule ? checkIfParentLoop(flowStore.val, flowModule.id) : undefined
@@ -404,7 +404,7 @@
on:createScriptFromInlineScript={async () => {
const [module, state] = await createScriptFromInlineScript(
flowModule,
$selectedId,
selectionManager.getSelectedId()!,
flowStateStore.val[flowModule.id].schema,
$pathStore
)
@@ -468,7 +468,7 @@
automaticLayout={true}
cmdEnterAction={async () => {
selected = 'test'
if ($selectedId == flowModule.id) {
if (selectionManager.getSelectedId() == flowModule.id) {
if (flowModule.value.type === 'rawscript' && editor) {
flowModule.value.content = editor.getCode()
}
@@ -578,7 +578,8 @@
class="px-2 xl:px-4"
bind:this={inputTransformSchemaForm}
pickableProperties={stepPropPicker.pickableProperties}
schema={flowStateStore.val[$selectedId]?.schema ?? {}}
schema={flowStateStore.val[selectionManager.getSelectedId()!]?.schema ??
{}}
previousModuleId={previousModule?.id}
bind:args={
() => {
@@ -609,7 +610,7 @@
bind:this={modulePreview}
mod={flowModule}
{noEditor}
schema={flowStateStore.val[$selectedId]?.schema ?? {}}
schema={flowStateStore.val[selectionManager.getSelectedId()!]?.schema ?? {}}
bind:testJob
bind:testIsLoading
bind:scriptProgress
@@ -623,7 +624,7 @@
active={flowModule.retry !== undefined}
label="Retries"
/>
{#if !$selectedId.includes('failure')}
{#if !selectionManager.getSelectedId()?.includes('failure')}
<Tab value="runtime" label="Runtime" />
<Tab value="cache" active={Boolean(flowModule.cache_ttl)} label="Cache" />
<Tab
@@ -838,7 +839,7 @@
<Button
btnClasses="mt-4"
on:click={() => {
$selectedId = 'settings-same-worker'
selectionManager.selectId('settings-same-worker')
}}
>
Set shared directory in the flow settings
@@ -20,7 +20,7 @@
let { flowModule = $bindable(), previousModuleId }: Props = $props()
const { selectedId, flowStore, flowStateStore, previewArgs } =
const { selectionManager, flowStore, flowStateStore, previewArgs } =
getContext<FlowEditorContext>('FlowEditorContext')
let schema = $state(emptySchema())
schema.properties['sleep'] = {
@@ -41,7 +41,7 @@
)
)
const result = flowStateStore.val[$selectedId]?.previewResult ?? {}
const result = flowStateStore.val[selectionManager.getSelectedId()!]?.previewResult ?? {}
let isSleepEnabled = $derived(Boolean(flowModule.sleep))
</script>
@@ -18,8 +18,8 @@
import EditableSchemaDrawer from '$lib/components/schema/EditableSchemaDrawer.svelte'
import AddProperty from '$lib/components/schema/AddProperty.svelte'
const { selectedId, flowStateStore } = getContext<FlowEditorContext>('FlowEditorContext')
const result = flowStateStore.val[$selectedId]?.previewResult ?? {}
const { selectionManager, flowStateStore } = getContext<FlowEditorContext>('FlowEditorContext')
const result = flowStateStore.val[selectionManager.getSelectedId()!]?.previewResult ?? {}
let editor: SimpleEditor | undefined = $state(undefined)
interface Props {
@@ -17,7 +17,7 @@
noLabel?: boolean
} = $props()
const { flowStore, selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
const { flowStore, selectionManager } = getContext<FlowEditorContext>('FlowEditorContext')
const dispatch = createEventDispatcher()
loadWorkerGroups()
@@ -44,7 +44,7 @@
<button
title="Worker Group is defined at the flow level"
class="w-full text-left items-center font-normal p-1 py-2 border text-xs rounded"
onclick={() => ($selectedId = 'settings-worker-group')}
onclick={() => selectionManager.selectId('settings-worker-group')}
>
Flow's WG: {flowStore.val.tag}
</button>
@@ -23,7 +23,7 @@
import { formatCron } from '$lib/utils'
import AgentToolWrapper from './AgentToolWrapper.svelte'
const { selectedId, flowStateStore } = getContext<FlowEditorContext>('FlowEditorContext')
const { selectionManager, flowStateStore } = getContext<FlowEditorContext>('FlowEditorContext')
const { triggersState, triggersCount } = getContext<TriggerContext>('TriggerContext')
@@ -113,7 +113,7 @@
}
</script>
{#if flowModule.id === $selectedId}
{#if flowModule.id === selectionManager.getSelectedId()!}
{#if flowModule.value.type === 'forloopflow'}
<FlowLoop {noEditor} bind:mod={flowModule} {parentModule} {previousModule} {enableAi} />
{:else if flowModule.value.type === 'whileloopflow'}
@@ -123,13 +123,13 @@
{:else if flowModule.value.type === 'branchall'}
<FlowBranchesAllWrapper {noEditor} {previousModule} {parentModule} bind:flowModule />
{:else if flowModule.value.type === 'identity'}
{#if $selectedId == 'failure'}
{#if selectionManager.getSelectedId() == 'failure'}
<div class="p-4">
<Alert type="info" title="Error handlers are triggered upon non recovered errors">
If defined, the error handler will take the error as input.
</Alert>
</div>
{:else if $selectedId == 'preprocessor'}
{:else if selectionManager.getSelectedId() == 'preprocessor'}
<div class="p-4">
<Alert
type="info"
@@ -157,8 +157,8 @@
summary={flowModule.summary}
shouldDisableTriggerScripts={parentModule !== undefined ||
previousModule !== undefined ||
$selectedId == 'failure' ||
$selectedId == 'preprocessor'}
selectionManager.getSelectedId() == 'failure' ||
selectionManager.getSelectedId() == 'preprocessor'}
on:pick={async ({ detail }) => {
const { path, summary, kind, hash } = detail
createModuleFromScript(path, summary, kind, hash)
@@ -187,8 +187,8 @@
flowModule = module
flowStateStore.val[module.id] = state
}}
failureModule={$selectedId === 'failure'}
preprocessorModule={$selectedId === 'preprocessor'}
failureModule={selectionManager.getSelectedId() === 'failure'}
preprocessorModule={selectionManager.getSelectedId() === 'preprocessor'}
/>
{/if}
{:else if flowModule.value.type === 'rawscript' || flowModule.value.type === 'script' || flowModule.value.type === 'flow' || flowModule.value.type === 'aiagent'}
@@ -197,8 +197,8 @@
bind:flowModule
{parentModule}
{previousModule}
failureModule={$selectedId === 'failure'}
preprocessorModule={$selectedId === 'preprocessor'}
failureModule={selectionManager.getSelectedId() === 'failure'}
preprocessorModule={selectionManager.getSelectedId() === 'preprocessor'}
{scriptKind}
{scriptTemplate}
{enableAi}
@@ -225,7 +225,7 @@
/>
{/each}
{:else if flowModule.value.type === 'branchone'}
{#if $selectedId === `${flowModule?.id}-branch-default`}
{#if selectionManager.getSelectedId() === `${flowModule?.id}-branch-default`}
<div class="p-2">
<h3 class="mb-4">Default branch</h3>
Nothing to configure, this is the default branch if none of the predicates are met.
@@ -247,7 +247,7 @@
{/each}
{/if}
{#each flowModule.value.branches as branch, branchIndex (branchIndex)}
{#if $selectedId === `${flowModule?.id}-branch-${branchIndex}`}
{#if selectionManager.getSelectedId() === `${flowModule?.id}-branch-${branchIndex}`}
<FlowBranchOneWrapper
{noEditor}
bind:branch={flowModule.value.branches[branchIndex]}
@@ -274,7 +274,7 @@
{/each}
{:else if flowModule.value.type === 'branchall'}
{#each flowModule.value.branches as branch, branchIndex (branchIndex)}
{#if $selectedId === `${flowModule?.id}-branch-${branchIndex}`}
{#if selectionManager.getSelectedId() === `${flowModule?.id}-branch-${branchIndex}`}
<FlowBranchAllWrapper {noEditor} bind:branch={flowModule.value.branches[branchIndex]} />
{:else}
{#each branch.modules as _, index}
@@ -295,7 +295,7 @@
{/each}
{:else if flowModule.value.type === 'aiagent'}
{#each flowModule.value.tools as tool, toolIndex (toolIndex)}
{#if $selectedId === tool.id}
{#if selectionManager.getSelectedId() === tool.id}
<AgentToolWrapper
{noEditor}
bind:tool={flowModule.value.tools[toolIndex]}
@@ -25,7 +25,7 @@
localModuleStates = $bindable({})
}: Props = $props()
const { selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
const { selectionManager } = getContext<FlowEditorContext>('FlowEditorContext')
let flowPreviewContent: FlowPreviewContent | undefined = $state(undefined)
let preventEscape = $state(false)
@@ -70,7 +70,7 @@
$state('timeline')
let upToDisabled = $derived.by(() => {
const upToSelected = upToId ?? $selectedId
const upToSelected = upToId ?? selectionManager.getSelectedId()
return (
upToSelected == undefined ||
[
@@ -92,7 +92,7 @@
'constants',
'Result',
'Input',
'triggers'
'Trigger'
].includes(upToSelected) ||
upToSelected?.includes('branch') ||
aiChatManager.flowAiChatHelpers?.getModuleAction(upToSelected) === 'removed'
@@ -144,8 +144,8 @@
dropdownItems={!upToDisabled
? [
{
label: 'Test up to ' + $selectedId,
onClick: () => testUpTo($selectedId, true)
label: 'Test up to ' + selectionManager.getSelectedId(),
onClick: () => testUpTo(selectionManager.getSelectedId(), true)
}
]
: undefined}
@@ -29,7 +29,7 @@
generateStep: { moduleId: string; instructions: string; lang: ScriptLang }
}>()
const { selectedId, flowStateStore, flowStore } =
const { selectionManager, flowStateStore, flowStore } =
getContext<FlowEditorContext>('FlowEditorContext')
async function insertFailureModule(
@@ -50,7 +50,7 @@
})
}
$selectedId = 'failure'
selectionManager.selectId('failure')
refreshStateStore(flowStore)
}
@@ -70,10 +70,10 @@
aiModuleActionToTextColor(action)
)}
id="flow-editor-error-handler"
selected={$selectedId?.includes('failure')}
selected={selectionManager.getSelectedId()?.includes('failure')}
onClick={() => {
if (flowStore.val?.value?.failure_module) {
$selectedId = 'failure'
selectionManager.selectId('failure')
}
}}
>
@@ -95,7 +95,7 @@
class="ml-1"
onclick={() => {
flowStore.val.value.failure_module = undefined
$selectedId = 'settings-metadata'
selectionManager.selectId('settings-metadata')
}}
>
<X size={12} />
@@ -105,7 +105,7 @@
let flowTutorials: FlowTutorials | undefined = $state(undefined)
const { customUi, selectedId, moving, history, flowStateStore, flowStore, pathStore } =
const { customUi, selectionManager, moving, history, flowStateStore, flowStore, pathStore } =
getContext<FlowEditorContext>('FlowEditorContext')
const { triggersCount, triggersState } = getContext<TriggerContext>('TriggerContext')
@@ -238,9 +238,9 @@
let allIds = dfs(flowStore.val.value.modules, (mod) => mod.id)
if (allIds.length > 1) {
const idx = allIds.indexOf(id)
$selectedId = idx == 0 ? allIds[0] : allIds[idx - 1]
selectionManager.selectId(idx == 0 ? allIds[0] : allIds[idx - 1])
} else {
$selectedId = 'settings-metadata'
selectionManager.selectId('settings-metadata')
}
}
}
@@ -431,7 +431,7 @@
flowStore.val.value.notes = newNotes
}}
preprocessorModule={flowStore.val.value?.preprocessor_module}
{selectedId}
{selectionManager}
{workspace}
editMode
{onTestUpTo}
@@ -450,7 +450,7 @@
const cb = () => {
push(history, flowStore.val)
if (id === 'preprocessor') {
$selectedId = 'Input'
selectionManager.selectId('Input')
flowStore.val.value.preprocessor_module = undefined
} else {
selectNextId(id)
@@ -509,7 +509,7 @@
let [removedModule] = originalModules.splice(indexToRemove, 1)
targetModules.splice(detail.index, 0, removedModule)
$selectedId = removedModule.id
selectionManager.selectId(removedModule.id)
$moving = undefined
} else {
if (detail.isPreprocessor) {
@@ -519,7 +519,7 @@
detail.inlineScript,
detail.script
)
$selectedId = 'preprocessor'
selectionManager.selectId('preprocessor')
if (detail.inlineScript?.instructions) {
dispatch('generateStep', {
@@ -546,7 +546,7 @@
toolKind
)
const id = targetModules[index].id
$selectedId = id
selectionManager.selectId(id)
if (detail.inlineScript?.instructions) {
dispatch('generateStep', {
@@ -631,13 +631,13 @@
flowStateStore.val[newId] = flowStateStore.val[id]
delete flowStateStore.val[id]
refreshStateStore(flowStore)
$selectedId = newId
selectionManager.selectId(newId)
}}
onDeleteBranch={async ({ id, index }) => {
if (id) {
await removeBranch(id, index)
refreshStateStore(flowStore)
$selectedId = id
selectionManager.selectId(id)
}
}}
onMove={(id) => {
@@ -658,6 +658,7 @@
{onOpenPreview}
{onHideJobStatus}
exitNoteMode={() => (noteMode = false)}
multiSelectEnabled
/>
</div>
</div>
@@ -32,7 +32,7 @@
disableAi
}: Props = $props()
const { selectedId, flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
const { selectionManager, flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
</script>
<div class="flex flex-row gap-2 p-1 rounded-md bg-surface">
@@ -41,10 +41,10 @@
unifiedSize="sm"
wrapperClasses="min-w-36"
startIcon={{ icon: Settings }}
selected={$selectedId?.startsWith('settings')}
selected={selectionManager.getSelectedId()?.startsWith('settings')}
variant="default"
title="Settings"
onClick={() => ($selectedId = 'settings')}
onClick={() => selectionManager.selectId('settings')}
>
Settings
{#if flowStore.val.value.same_worker}
@@ -64,10 +64,10 @@
wrapperClasses="h-full"
unifiedSize="sm"
startIcon={{ icon: DollarSign }}
selected={$selectedId === 'constants'}
selected={selectionManager.getSelectedId() === 'constants'}
variant="default"
iconOnly
onClick={() => ($selectedId = 'constants')}
onClick={() => selectionManager.selectId('constants')}
/>
{#snippet text()}
Static inputs
@@ -2,7 +2,6 @@
import { Button } from '$lib/components/common'
import type { FlowModule, Job } from '$lib/gen'
import { createEventDispatcher, getContext } from 'svelte'
import type { Writable } from 'svelte/store'
import FlowModuleSchemaItem from './FlowModuleSchemaItem.svelte'
import FlowModuleIcon from '../FlowModuleIcon.svelte'
import { prettyLanguage } from '$lib/common'
@@ -17,6 +16,7 @@
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
@@ -74,9 +74,7 @@
maximizeSubflow
}: Props = $props()
const { selectedId } = getContext<{
selectedId: Writable<string | undefined>
}>('FlowGraphContext')
const { selectionManager } = getGraphContext()
const { flowStore } = getContext<FlowEditorContext | undefined>('FlowEditorContext') || {}
@@ -88,7 +86,9 @@
}>()
let itemProps = $derived({
selected: $selectedId === mod.id,
selected:
selectionManager?.getSelectedId() === mod.id ||
(selectionManager && selectionManager.selectedIds.includes(mod.id)),
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),
+4 -13
View File
@@ -1,4 +1,4 @@
import type { Job, OpenFlow } from '$lib/gen'
import type { FlowNote, Job, OpenFlow } from '$lib/gen'
import type { History } from '$lib/history.svelte'
import type { Writable } from 'svelte/store'
import type ScriptEditorDrawer from './content/ScriptEditorDrawer.svelte'
@@ -15,16 +15,7 @@ import type ResourceEditorDrawer from '../ResourceEditorDrawer.svelte'
import type { ModulesTestStates } from '../modulesTest.svelte'
import type { ButtonProp } from '$lib/components/DiffEditor.svelte'
import type { NoteColor } from '../graph/noteColors'
// Type for flow notes stored in the UI field
export type Note = {
id: string
text: string
position: { x: number; y: number }
size: { width: number; height: number }
color: NoteColor
}
import type { SelectionManager } from '../graph/selectionUtils.svelte'
export type FlowInput = Record<
string,
@@ -47,7 +38,7 @@ export type ExtendedOpenFlow = OpenFlow & {
visible_to_runner_only?: boolean
on_behalf_of_email?: string
ui?: {
notes?: Note[]
notes?: FlowNote[]
}
}
@@ -83,7 +74,7 @@ export type CurrentEditor =
| undefined
export type FlowEditorContext = {
selectedId: Writable<string>
selectionManager: SelectionManager
currentEditor: Writable<CurrentEditor>
moving: Writable<{ id: string } | undefined>
previewArgs: StateStore<Record<string, any>>
@@ -1,9 +1,8 @@
<script lang="ts">
import { FlowService, type FlowModule, type Job } from '../../gen'
import { FlowService, type FlowModule, type FlowNote, type Job } from '../../gen'
import { NODE, type GraphModuleState } from '.'
import type { Note } from '../flows/types'
import { DEFAULT_NOTE_COLOR, type NoteColor } from './noteColors'
import { getContext, onDestroy, setContext, tick, untrack, type Snippet } from 'svelte'
import { getContext, onDestroy, tick, untrack, type Snippet } from 'svelte'
import { get, writable, type Writable } from 'svelte/store'
import '@xyflow/svelte/dist/base.css'
@@ -37,7 +36,7 @@
import BaseEdge from './renderers/edges/BaseEdge.svelte'
import EmptyEdge from './renderers/edges/EmptyEdge.svelte'
import { sugiyama, dagStratify, coordCenter, decrossTwoLayer, decrossOpt } from 'd3-dag'
import { Expand } from 'lucide-svelte'
import { Expand, MousePointer } from 'lucide-svelte'
import Toggle from '../Toggle.svelte'
import DataflowEdge from './renderers/edges/DataflowEdge.svelte'
import { encodeState, readFieldsRecursively } from '$lib/utils'
@@ -61,11 +60,15 @@
import NewAiToolNode from './renderers/nodes/NewAIToolNode.svelte'
import NoteNode from './renderers/nodes/NoteNode.svelte'
import NoteTool from './NoteTool.svelte'
import SelectionBoundingBox from './SelectionBoundingBox.svelte'
import SelectionTool from './SelectionTool.svelte'
import { SelectionManager } from './selectionUtils.svelte'
import { ChangeTracker } from '$lib/svelte5Utils.svelte'
import type { ModulesTestStates } from '../modulesTest.svelte'
import { deepEqual } from 'fast-equals'
import type { AssetWithAltAccessType } from '../assets/lib'
import type { AIModuleAction } from '../copilot/chat/flow/core'
import { setGraphContext } from './graphContext'
let useDataflow: Writable<boolean | undefined> = writable<boolean | undefined>(false)
let showAssets: Writable<boolean | undefined> = writable<boolean | undefined>(true)
@@ -89,7 +92,7 @@
testModuleStates?: ModulesTestStates
moduleActions?: Record<string, AIModuleAction>
inputSchemaModified?: boolean
selectedId?: Writable<string | undefined>
selectionManager?: SelectionManager
path?: string | undefined
newFlow?: boolean
insertable?: boolean
@@ -113,9 +116,10 @@
showJobStatus?: boolean
suspendStatus?: Record<string, { job: Job; nb: number }>
noteMode?: boolean
notes?: Note[]
notes?: FlowNote[]
chatInputEnabled?: boolean
onNotesChange?: (notes: Note[]) => void
multiSelectEnabled?: boolean
onNotesChange?: (notes: FlowNote[]) => void
onDelete?: (id: string) => void
onInsert?: (detail: {
sourceId?: string
@@ -173,7 +177,7 @@
testModuleStates = undefined,
moduleActions = undefined,
inputSchemaModified = undefined,
selectedId = writable<string | undefined>(undefined),
selectionManager = undefined,
path = undefined,
newFlow = false,
insertable = false,
@@ -210,14 +214,18 @@
chatInputEnabled = false,
sharedViewport = undefined,
onViewportChange = undefined,
leftHeader = undefined
leftHeader = undefined,
multiSelectEnabled = false
}: Props = $props()
setContext<{
selectedId: Writable<string | undefined>
useDataflow: Writable<boolean | undefined>
showAssets: Writable<boolean | undefined>
}>('FlowGraphContext', { selectedId, useDataflow, showAssets })
// Selection manager - create one if not provided
let actualSelectionManager = selectionManager || new SelectionManager()
setGraphContext({
selectionManager: actualSelectionManager,
useDataflow,
showAssets
})
if (triggerContext && allowSimplifiedPoll) {
if (isSimplifiable(modules)) {
@@ -327,7 +335,7 @@
let eventHandler = {
deleteBranch: (detail, label) => {
$selectedId = label
actualSelectionManager.selectId(label)
onDeleteBranch?.(detail)
},
insert: (detail) => {
@@ -335,9 +343,9 @@
},
select: (modId) => {
if (!notSelectable) {
if ($selectedId != modId) {
$selectedId = modId
}
// TODO: Handle Ctrl/Cmd and Shift modifiers when node-level click events are available
// For now, normal click behavior
actualSelectionManager.selectId(modId)
onSelect?.(modId)
}
},
@@ -413,10 +421,19 @@
return false
}
// Keyboard event handling
function handleKeyDown(event: KeyboardEvent) {
actualSelectionManager.handleKeyDown(event, nodes)
}
function handleKeyUp(event: KeyboardEvent) {
// Keep for potential future use
}
function onNoteAdded(newNoteFromTool: any) {
// Add the note to our separate notes array if a note was created
if (newNoteFromTool && onNotesChange) {
const newNote: Note = {
const newNote: FlowNote = {
id: `note-${nextNoteId}`,
text: '',
position: newNoteFromTool.position,
@@ -589,7 +606,7 @@
testModuleStates: untrack(() => testModuleStates),
moduleActions: untrack(() => moduleActions),
inputSchemaModified: untrack(() => inputSchemaModified),
selectedId: untrack(() => $selectedId),
selectedId: untrack(() => actualSelectionManager.getSelectedId()),
path,
newFlow,
cache,
@@ -611,7 +628,7 @@
eventHandler,
success,
$useDataflow,
untrack(() => $selectedId),
untrack(() => actualSelectionManager.getSelectedId()),
moving,
simplifiableFlow,
triggerNode ? path : undefined,
@@ -624,14 +641,44 @@
untrack(() => updateStores())
})
// Add global keyboard event listener for selection controls
$effect(() => {
function globalKeyDownHandler(event: KeyboardEvent) {
// Only handle if the graph container has focus or no input is focused
const activeElement = document.activeElement
const isInputFocused =
activeElement &&
(activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
(activeElement as HTMLElement).contentEditable === 'true')
if (!isInputFocused) {
handleKeyDown(event)
}
}
function globalKeyUpHandler(event: KeyboardEvent) {
handleKeyUp(event)
}
document.addEventListener('keydown', globalKeyDownHandler)
document.addEventListener('keyup', globalKeyUpHandler)
return () => {
document.removeEventListener('keydown', globalKeyDownHandler)
document.removeEventListener('keyup', globalKeyUpHandler)
}
})
let showDataflow = $derived(
$selectedId != undefined &&
!$selectedId.startsWith('constants') &&
!$selectedId.startsWith('settings') &&
$selectedId !== 'failure' &&
$selectedId !== 'preprocessor' &&
$selectedId !== 'Result' &&
$selectedId !== 'triggers'
actualSelectionManager.getSelectedId() !== undefined &&
actualSelectionManager.getSelectedId() !== null &&
!actualSelectionManager.getSelectedId()?.startsWith('constants') &&
!actualSelectionManager.getSelectedId()?.startsWith('settings') &&
actualSelectionManager.getSelectedId() !== 'failure' &&
actualSelectionManager.getSelectedId() !== 'preprocessor' &&
actualSelectionManager.getSelectedId() !== 'Result' &&
actualSelectionManager.getSelectedId() !== 'Trigger'
)
let debouncedWidth: number | undefined = $state(undefined)
let timeout: number | undefined = $state(undefined)
@@ -667,6 +714,8 @@
export function zoomOut() {
viewportSynchronizer?.zoomOut()
}
$inspect('dbg modules', modules, nodes)
</script>
{#if insertable}
@@ -674,7 +723,7 @@
{/if}
<div
style={`height: ${height}px; max-height: ${maxHeight}px;`}
class="overflow-clip"
class="overflow-clip relative"
bind:clientWidth={debouncedWidth}
>
{#if graph?.error}
@@ -703,6 +752,9 @@
<SvelteFlow
onpaneclick={() => {
document.dispatchEvent(new Event('focus'))
if (actualSelectionManager.mode === 'normal') {
actualSelectionManager.clearSelection()
}
}}
onnodedragstop={(event) => {
const node = event.targetNode
@@ -736,12 +788,46 @@
<NoteTool {onNoteAdded} />
{/if}
<SelectionBoundingBox
selectedNodes={nodes.filter((node) =>
actualSelectionManager.selectedIds.includes(node.id)
)}
/>
<SelectionTool
selectionMode={actualSelectionManager.mode}
onNodesSelected={(nodeIds, addToExisting) =>
actualSelectionManager.selectNodes(nodeIds, addToExisting, modules, nodes)}
{nodes}
/>
{#if leftHeader}
<div class="absolute top-2 left-2 z-10">
{@render leftHeader()}
</div>
{:else}
<Controls position="top-right" orientation="horizontal" showLock={false}>
{#if multiSelectEnabled}
<div class="flex items-center gap-2">
<ControlButton
onclick={() => {
actualSelectionManager.mode =
actualSelectionManager.mode === 'normal' ? 'rect-select' : 'normal'
}}
title="Toggle rectangle selection"
class={actualSelectionManager.mode === 'rect-select'
? 'text-accent !bg-surface-selected'
: ''}
>
<MousePointer size="14" />
</ControlButton>
{#if actualSelectionManager.selectedIds.length > 0}
<span class="text-xs text-secondary"
>{actualSelectionManager.selectedIds.length} selected</span
>
{/if}
</div>
{/if}
{#if download}
<ControlButton
onclick={() => {
@@ -0,0 +1,53 @@
<script lang="ts">
import type { Node } from '@xyflow/svelte'
import { NODE } from './util'
interface Props {
selectedNodes: Node[]
}
let { selectedNodes }: Props = $props()
let bounds = $derived(() => {
if (selectedNodes.length === 0) {
return null
}
let minX = Infinity
let maxX = -Infinity
let minY = Infinity
let maxY = -Infinity
selectedNodes.forEach(node => {
minX = Math.min(minX, node.position.x)
maxX = Math.max(maxX, node.position.x + NODE.width)
minY = Math.min(minY, node.position.y)
maxY = Math.max(maxY, node.position.y + NODE.height)
})
return {
x: minX - 10, // Add padding
y: minY - 10,
width: maxX - minX + 20,
height: maxY - minY + 20
}
})
</script>
{#if bounds() && selectedNodes.length > 1}
{@const currentBounds = bounds()!}
<div
class="absolute pointer-events-none border-2 border-dashed border-accent bg-accent/5 rounded"
style="
left: {currentBounds.x}px;
top: {currentBounds.y}px;
width: {currentBounds.width}px;
height: {currentBounds.height}px;
z-index: -1;
"
>
<div class="absolute -top-6 left-0 text-xs text-accent font-medium bg-surface px-2 py-1 rounded shadow">
{selectedNodes.length} nodes selected
</div>
</div>
{/if}
@@ -0,0 +1,180 @@
<script lang="ts">
import { useSvelteFlow, type XYPosition } from '@xyflow/svelte'
import { NODE } from './util'
interface Props {
selectionMode: 'normal' | 'rect-select'
onNodesSelected: (nodeIds: string[], addToExisting: boolean) => void
nodes: any[]
}
let { selectionMode, onNodesSelected, nodes }: Props = $props()
const { screenToFlowPosition } = useSvelteFlow()
let isDrawing = $state(false)
let startPosition: XYPosition | null = $state(null)
let endPosition: XYPosition | null = $state(null)
let rect: DOMRect | null = $state(null)
function onPointerDown(event: PointerEvent) {
if (selectionMode !== 'rect-select') return
// Allow middle-click (button 1) to pass through for graph panning
if (event.button === 1) {
return
}
// Only handle left-click (button 0) for rectangle selection
if (event.button !== 0) return
// Capture pointer to continue tracking outside the element
const target = event.currentTarget as Element
target?.setPointerCapture?.(event.pointerId)
// Use page coordinates as reference
rect = target.getBoundingClientRect()
startPosition = {
x: event.pageX - rect.left,
y: event.pageY - rect.top
}
endPosition = startPosition
isDrawing = true
event.preventDefault()
}
function onPointerMove(event: PointerEvent) {
if (!isDrawing || !rect) return
// Use page coordinates as reference
endPosition = {
x: event.pageX - rect.left,
y: event.pageY - rect.top
}
}
function onPointerUp(event: PointerEvent) {
if (!isDrawing || !startPosition || !endPosition || !rect) return
// Only proceed if we have a meaningful selection area
const deltaX = Math.abs(endPosition.x - startPosition.x)
const deltaY = Math.abs(endPosition.y - startPosition.y)
if (deltaX > 5 || deltaY > 5) {
// Convert the start and end positions to absolute positions
const absoluteStartPosition = {
x: startPosition.x + rect.left,
y: startPosition.y + rect.top
}
const absoluteEndPosition = {
x: endPosition.x + rect.left,
y: endPosition.y + rect.top
}
// Convert to flow coordinates
const flowStart = screenToFlowPosition({
x: Math.min(absoluteStartPosition.x, absoluteEndPosition.x),
y: Math.min(absoluteStartPosition.y, absoluteEndPosition.y)
})
const flowEnd = screenToFlowPosition({
x: Math.max(absoluteStartPosition.x, absoluteEndPosition.x),
y: Math.max(absoluteStartPosition.y, absoluteEndPosition.y)
})
// Find nodes within the selection rectangle
const selectedNodeIds = getNodesInFlowRectangle({
x1: flowStart.x,
y1: flowStart.y,
x2: flowEnd.x,
y2: flowEnd.y
})
if (selectedNodeIds.length > 0) {
onNodesSelected(selectedNodeIds, event.shiftKey)
}
}
// Reset state
isDrawing = false
startPosition = null
endPosition = null
rect = null
}
function getNodesInFlowRectangle(rect: {
x1: number
y1: number
x2: number
y2: number
}): string[] {
const minX = Math.min(rect.x1, rect.x2)
const maxX = Math.max(rect.x1, rect.x2)
const minY = Math.min(rect.y1, rect.y2)
const maxY = Math.max(rect.y1, rect.y2)
return nodes
.filter((node) => {
const nodeMinX = node.position.x
const nodeMaxX = node.position.x + NODE.width
const nodeMinY = node.position.y
const nodeMaxY = node.position.y + NODE.height
// Check if node intersects with selection rectangle
return !(nodeMaxX < minX || nodeMinX > maxX || nodeMaxY < minY || nodeMinY > maxY)
})
.map((node) => node.id)
}
const previewNote = $derived.by(() => {
if (!startPosition || !endPosition) return null
return {
position: {
x: Math.min(startPosition.x, endPosition.x),
y: Math.min(startPosition.y, endPosition.y)
},
size: {
width: Math.abs(endPosition.x - startPosition.x),
height: Math.abs(endPosition.y - startPosition.y)
}
}
})
</script>
{#if selectionMode === 'rect-select'}
<div
class="selection-overlay"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
role="button"
tabindex="0"
aria-label="Click and drag to select nodes"
>
<!-- Preview selection rectangle while drawing -->
{#if previewNote && isDrawing}
<div
class="absolute border-2 border-dashed border-accent bg-accent/10 rounded pointer-events-none"
style="
width: {previewNote.size.width}px;
height: {previewNote.size.height}px;
transform: translate({previewNote.position.x}px, {previewNote.position.y}px);
"
></div>
{/if}
</div>
{/if}
<style>
.selection-overlay {
pointer-events: auto;
position: absolute;
top: 0;
left: 0;
z-index: 50;
height: 100%;
width: 100%;
cursor: crosshair;
touch-action: none;
}
</style>
@@ -0,0 +1,15 @@
import { getContext, setContext } from 'svelte'
import type { SelectionManager } from './selectionUtils.svelte'
import type { Writable } from 'svelte/store'
type GraphContext = {
selectionManager: SelectionManager
useDataflow: Writable<boolean | undefined>
showAssets: Writable<boolean | undefined>
}
const graphContextKey = 'FlowGraphContext'
//TODO: use https://svelte.dev/docs/svelte/context#Type-safe-context after migrating svelte 5 to latest version
export const getGraphContext = () => getContext<GraphContext>(graphContextKey)
export const setGraphContext = (context: GraphContext) => setContext(graphContextKey, context)
@@ -2,8 +2,6 @@
import InsertModulePopover from '$lib/components/flows/map/InsertModulePopover.svelte'
import { getBezierPath, BaseEdge, type EdgeProps, EdgeLabel } from '@xyflow/svelte'
import { ClipboardCopy, Hourglass } from 'lucide-svelte'
import { getContext } from 'svelte'
import type { Writable } from 'svelte/store'
import type { GraphEventHandlers } from '../../graphBuilder.svelte'
import { getStraightLinePath } from '../utils'
import { twMerge } from 'tailwind-merge'
@@ -13,11 +11,9 @@
import type { Job } from '$lib/gen'
import type { GraphModuleState } from '../../model'
import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte'
import { getGraphContext } from '../../graphContext'
const { useDataflow, showAssets } = getContext<{
useDataflow: Writable<boolean | undefined>
showAssets?: Writable<boolean>
}>('FlowGraphContext')
const { useDataflow, showAssets } = getGraphContext()
let {
// id,
@@ -1,8 +1,7 @@
<script lang="ts">
import { getBezierPath, BaseEdge, type Position } from '@xyflow/svelte'
import { getContext } from 'svelte'
import type { Writable } from 'svelte/store'
import { twMerge } from 'tailwind-merge'
import { getGraphContext } from '../../graphContext'
interface Props {
sourceX: number
@@ -26,9 +25,7 @@
data = {}
}: Props = $props()
const { useDataflow } = getContext<{
useDataflow: Writable<boolean | undefined>
}>('FlowGraphContext')
const { useDataflow } = getGraphContext()
let [edgePath] = $derived(
getBezierPath({
@@ -253,13 +253,12 @@
} from '../../graphBuilder.svelte'
import { MessageCircle, Play, Plug, Wrench, X } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { getContext } from 'svelte'
import type { Edge, Node } from '@xyflow/svelte'
import type { Writable } from 'svelte/store'
import type { GraphModuleState } from '../../model'
import { getNodeColorClasses } from '../../util'
import { deepEqual } from 'fast-equals'
import { getGraphContext } from '../../graphContext'
let hover = $state(false)
@@ -269,15 +268,13 @@
let { data }: Props = $props()
const { selectedId } = getContext<{
selectedId: Writable<string | undefined>
}>('FlowGraphContext')
const { selectionManager } = getGraphContext()
const flowModuleState = $derived(data.flowModuleStates?.[data.moduleId])
let colorClasses = $derived(
getNodeColorClasses(
!validateToolName(data.tool) ? 'Failure' : flowModuleState?.type,
$selectedId === data.moduleId
selectionManager?.getSelectedId() === data.moduleId
)
)
</script>
@@ -322,7 +319,7 @@
<button
class={twMerge(
'absolute -top-[8px] -right-[8px] rounded-full h-[16px] w-[16px] center-center text-secondary outline-[1px] outline dark:outline-gray-500 outline-gray-300 bg-surface duration-0 hover:bg-red-400 hover:text-white !hidden',
$selectedId === data.moduleId || hover ? '!flex' : ''
selectionManager?.getSelectedId() === data.moduleId || hover ? '!flex' : ''
)}
title="Delete"
onclick={() => data.eventHandlers.delete({ id: data.moduleId }, '')}
@@ -2,12 +2,14 @@
import VirtualItem from '$lib/components/flows/map/VirtualItem.svelte'
import NodeWrapper from './NodeWrapper.svelte'
import type { BranchAllEndN } from '../../graphBuilder.svelte'
import { getGraphContext } from '../../graphContext'
interface Props {
data: BranchAllEndN['data']
}
let { data }: Props = $props()
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset} enableSourceHandle enableTargetHandle>
@@ -16,7 +18,7 @@
label={'Collect result from all branches'}
id={data.id}
selectable={true}
selected={false}
selected={selectionManager?.isNodeSelected(data.id)}
on:select={(e) => {
data?.eventHandlers?.select(e.detail)
}}
@@ -5,12 +5,14 @@
import NodeWrapper from './NodeWrapper.svelte'
import { X } from 'lucide-svelte'
import type { BranchAllStartN } from '../../graphBuilder.svelte'
import { getGraphContext } from '../../graphContext'
interface Props {
data: BranchAllStartN['data']
}
let { data }: Props = $props()
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset}>
@@ -18,7 +20,7 @@
<VirtualItem
label={data.label}
selectable
selected={false}
selected={selectionManager?.isNodeSelected(data.id)}
on:select={() => {
setTimeout(() => data.eventHandlers.select(data.id))
}}
@@ -5,12 +5,14 @@
import NodeWrapper from './NodeWrapper.svelte'
import { X } from 'lucide-svelte'
import type { BranchOneStartN } from '../../graphBuilder.svelte'
import { getGraphContext } from '../../graphContext'
interface Props {
data: BranchOneStartN['data']
}
let { data }: Props = $props()
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset}>
@@ -19,7 +21,7 @@
label={data.label}
preLabel={data.preLabel}
selectable
selected={data.selected}
selected={selectionManager?.isNodeSelected(data.id)}
on:select={() => {
setTimeout(() => data?.eventHandlers?.select(data.id))
}}
@@ -2,12 +2,14 @@
import VirtualItem from '$lib/components/flows/map/VirtualItem.svelte'
import NodeWrapper from './NodeWrapper.svelte'
import type { ForLoopEndN } from '../../graphBuilder.svelte'
import { getGraphContext } from '../../graphContext'
interface Props {
data: ForLoopEndN['data']
}
let { data }: Props = $props()
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset}>
@@ -16,7 +18,7 @@
<VirtualItem
label={'Each event is processed'}
selectable={false}
selected={false}
selected={selectionManager?.isNodeSelected(data.id)}
id={data.id}
hideId
on:select={(e) => {
@@ -27,7 +29,7 @@
<VirtualItem
label={'Collect result of each iteration'}
selectable={true}
selected={false}
selected={selectionManager?.isNodeSelected(data.id)}
id={data.id}
on:select={(e) => {
setTimeout(() => data?.eventHandlers?.select(e.detail))
@@ -5,7 +5,7 @@
import { getContext } from 'svelte'
import type { PropPickerContext } from '$lib/components/prop_picker'
import type { ForLoopStartN } from '../../graphBuilder.svelte'
import { getGraphContext } from '../../graphContext'
interface Props {
data: ForLoopStartN['data']
}
@@ -51,6 +51,8 @@
return 'none'
}
let filteredInput = $derived(filterIterFromInput($pickablePropertiesFiltered?.flow_input))
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset}>
@@ -58,7 +60,7 @@
<VirtualItem
label={data.simplifiedTriggerView ? 'For each new event' : 'Do one iteration'}
selectable={false}
selected={false}
selected={selectionManager?.isNodeSelected(data.id)}
id={data.id}
hideId
on:select={(e) => {
@@ -3,7 +3,7 @@
import NodeWrapper from './NodeWrapper.svelte'
import type { InputN } from '../../graphBuilder.svelte'
import { getContext } from 'svelte'
import type { Writable } from 'svelte/store'
import InsertModulePopover from '$lib/components/flows/map/InsertModulePopover.svelte'
import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte'
import { schemaToObject } from '$lib/schema'
@@ -11,6 +11,7 @@
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']
@@ -18,9 +19,7 @@
let { data }: Props = $props()
const { selectedId } = getContext<{
selectedId: Writable<string | undefined>
}>('FlowGraphContext')
const { selectionManager } = getGraphContext()
const { previewArgs, flowStore } =
getContext<FlowEditorContext | undefined>('FlowEditorContext') || {}
@@ -82,7 +81,7 @@
hideId={true}
label={inputLabel}
selectable
selected={$selectedId === 'Input'}
selected={selectionManager?.isNodeSelected('Input')}
on:insert={(e) => {
setTimeout(() => data?.eventHandlers?.insert(e.detail))
}}
@@ -2,12 +2,14 @@
import VirtualItem from '$lib/components/flows/map/VirtualItem.svelte'
import NodeWrapper from './NodeWrapper.svelte'
import type { NoBranchN } from '../../graphBuilder.svelte'
import { getGraphContext } from '../../graphContext'
interface Props {
data: NoBranchN['data']
}
let { data }: Props = $props()
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset} enableSourceHandle enableTargetHandle>
@@ -17,7 +19,7 @@
id={data.id}
hideId={true}
selectable={true}
selected={false}
selected={selectionManager?.isNodeSelected(data.id)}
on:select={(e) => {
setTimeout(() => data?.eventHandlers?.select(e.detail))
}}
@@ -1,9 +1,8 @@
<script lang="ts">
import VirtualItem from '$lib/components/flows/map/VirtualItem.svelte'
import NodeWrapper from './NodeWrapper.svelte'
import type { Writable } from 'svelte/store'
import { getContext } from 'svelte'
import type { ResultN } from '../../graphBuilder.svelte'
import { getGraphContext } from '../../graphContext'
interface Props {
data: ResultN['data']
@@ -11,9 +10,7 @@
let { data }: Props = $props()
const { selectedId } = getContext<{
selectedId: Writable<string | undefined>
}>('FlowGraphContext')
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper enableSourceHandle={false}>
@@ -22,7 +19,7 @@
id={'Result'}
label={'Result'}
selectable={true}
selected={$selectedId === 'Result'}
selected={selectionManager?.getSelectedId() === 'Result'}
hideId={true}
on:select={(e) => {
setTimeout(() => data?.eventHandlers?.select(e.detail))
@@ -1,9 +1,10 @@
<script lang="ts">
import { preventDefault, stopPropagation } from 'svelte/legacy'
import NodeWrapper from './NodeWrapper.svelte'
import TriggersWrapper from '../triggers/TriggersWrapper.svelte'
import type { FlowModule, TriggersCount } from '$lib/gen'
import { getContext } from 'svelte'
import type { Writable } from 'svelte/store'
import { Maximize2, Minimize2, Calendar } from 'lucide-svelte'
import { getNodeColorClasses } from '../../util'
import { setScheduledPollSchedule, type TriggerContext } from '$lib/components/triggers'
@@ -11,24 +12,27 @@
import { type Trigger, type TriggerType } from '$lib/components/triggers/utils'
import { tick } from 'svelte'
import type { GraphEventHandlers, SimplifiableFlow } from '../../graphBuilder.svelte'
import { getGraphContext } from '../../graphContext'
export let data: {
path: string
isEditor: boolean
newFlow: boolean
extra_perms: Record<string, any>
eventHandlers: GraphEventHandlers
modules: FlowModule[]
index: number
disableAi: boolean
simplifiableFlow: SimplifiableFlow
interface Props {
data: {
path: string
isEditor: boolean
newFlow: boolean
extra_perms: Record<string, any>
eventHandlers: GraphEventHandlers
modules: FlowModule[]
index: number
disableAi: boolean
simplifiableFlow: SimplifiableFlow
}
}
const { selectedId } = getContext<{
selectedId: Writable<string | undefined>
}>('FlowGraphContext')
let { data }: Props = $props()
const { triggersCount, triggersState } = getContext<TriggerContext>('TriggerContext')
const { selectionManager } = getGraphContext()
const { triggersCount, triggersState } = $state(getContext<TriggerContext>('TriggerContext'))
function getScheduleCfg(primary: Trigger | undefined, triggersCount: TriggersCount | undefined) {
return primary?.draftConfig
@@ -44,7 +48,9 @@
}
}
$: colorClasses = getNodeColorClasses('_VirtualItem', $selectedId == 'triggers')
let colorClasses = $derived(
getNodeColorClasses('_VirtualItem', selectionManager?.isNodeSelected('Trigger'))
)
</script>
<NodeWrapper>
@@ -76,26 +82,26 @@
const primarySchedule = triggersState.triggers.findIndex((t) => t.isPrimary && !t.isDraft)
triggersState.selectedTriggerIndex = primarySchedule
}}
on:select={() => data?.eventHandlers?.select('triggers')}
on:select={() => data?.eventHandlers?.select('Trigger')}
onSelect={async (triggerIndex: number) => {
data?.eventHandlers?.select('triggers')
data?.eventHandlers?.select('Trigger')
await tick()
triggersState.selectedTriggerIndex = triggerIndex
}}
onAddDraftTrigger={async (type: TriggerType) => {
const newTrigger = triggersState.addDraftTrigger(triggersCount, type)
data?.eventHandlers?.select('triggers')
data?.eventHandlers?.select('Trigger')
await tick()
triggersState.selectedTriggerIndex = newTrigger
}}
selected={$selectedId == 'triggers'}
selected={selectionManager?.getSelectedId() === 'Trigger'}
newItem={data.newFlow}
/>
{:else}
<VirtualItemWrapper
label="Check for new events"
selectable={true}
id={'triggers'}
id={'Trigger'}
on:select={(e) => {
data?.eventHandlers?.select(e.detail)
}}
@@ -116,7 +122,7 @@
{:else}
<button
class="px-2 py-1 hover:bg-surface-inverse w-full hover:text-primary-inverse"
on:click={() => {
onclick={() => {
setScheduledPollSchedule(triggersState, triggersCount)
}}
>
@@ -129,8 +135,11 @@
<button
class="absolute -top-[10px] -right-[10px] rounded-full h-[20px] w-[20px] trash center-center text-secondary
outline-[1px] outline dark:outline-gray-500 outline-gray-300 bg-surface duration-0 hover:bg-nord-950 hover:text-white"
on:click|preventDefault|stopPropagation={() =>
data?.eventHandlers?.simplifyFlow(!data.simplifiableFlow?.simplifiedFlow)}
onclick={stopPropagation(
preventDefault(() =>
data?.eventHandlers?.simplifyFlow(!data.simplifiableFlow?.simplifiedFlow)
)
)}
title={data.simplifiableFlow?.simplifiedFlow
? 'Expand to full flow view'
: 'Simplify flow view for scheduled poll'}
@@ -2,12 +2,14 @@
import VirtualItem from '$lib/components/flows/map/VirtualItem.svelte'
import NodeWrapper from './NodeWrapper.svelte'
import type { BranchOneEndN } from '../../graphBuilder.svelte'
import { getGraphContext } from '../../graphContext'
interface Props {
data: BranchOneEndN['data']
}
let { data }: Props = $props()
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset}>
@@ -16,7 +18,7 @@
label={'Collect result from chosen branch'}
id={data.id}
selectable={true}
selected={false}
selected={selectionManager?.isNodeSelected(data.id)}
on:select={(e) => {
setTimeout(() => data?.eventHandlers?.select(e.detail))
}}
@@ -0,0 +1,194 @@
import type { FlowModule } from '$lib/gen'
import type { Node } from '@xyflow/svelte'
export interface SelectionState {
selectedId: string | undefined
selectedIds: string[]
selectionMode: 'normal' | 'rect-select'
}
export class SelectionManager {
public selectedIds = $state<string[]>([])
#selectionMode = $state<'normal' | 'rect-select'>('normal')
constructor() {}
selectId(id: string) {
this.selectedIds = [id]
}
getSelectedId(): string | undefined {
return this.selectedIds[0] || undefined
}
get mode() {
return this.#selectionMode
}
set mode(mode: 'normal' | 'rect-select') {
this.#selectionMode = mode
if (mode === 'normal') {
// When exiting rect mode, preserve the first item if there are selections
const firstSelected = this.selectedIds[0]
this.selectId(firstSelected ?? 'settings')
}
}
// Get hierarchical children of a node
getNodeChildrenIds(nodeId: string, modules: FlowModule[] | undefined, nodes: Node[]): string[] {
const module = modules?.find((m) => m.id === nodeId)
if (!module) return []
const childrenIds: string[] = []
// For hierarchical modules, find all children between start and end using proper graph traversal
if (
module.value.type === 'forloopflow' ||
module.value.type === 'whileloopflow' ||
module.value.type === 'branchall' ||
module.value.type === 'branchone'
) {
const endNodeId = `${nodeId}-end`
const endNode = nodes.find((n) => n.id === endNodeId)
if (endNode) {
// Traverse from end node back to start using parentIds
const visited = new Set<string>()
const toVisit = [endNodeId]
while (toVisit.length > 0) {
const currentId = toVisit.shift()!
if (visited.has(currentId) || currentId === nodeId) continue
visited.add(currentId)
const currentNode = nodes.find((n) => n.id === currentId)
if (currentNode && (currentNode as any).parentIds) {
const parentIds = (currentNode as any).parentIds as string[]
for (const parentId of parentIds) {
if (parentId !== nodeId && !visited.has(parentId)) {
childrenIds.push(parentId)
toVisit.push(parentId)
}
}
}
}
}
}
return childrenIds
}
// Select nodes with optional hierarchical selection
selectNodes(nodeIds: string[], addToExisting = false, modules?: FlowModule[], nodes?: Node[]) {
// Guard against empty nodeIds or uninitialized state
if (!nodeIds || nodeIds.length === 0) {
if (!addToExisting) {
this.clearSelection()
}
return
}
const newSelection = addToExisting ? [...this.selectedIds] : []
nodeIds.forEach((nodeId) => {
// Only add valid node IDs that exist in the current nodes
if (!nodes || nodes.some((node) => node.id === nodeId)) {
if (!newSelection.includes(nodeId)) {
newSelection.push(nodeId)
}
// Auto-select children for hierarchical modules
if (modules && nodes) {
const children = this.getNodeChildrenIds(nodeId, modules, nodes)
children.forEach((childId) => {
if (nodes.some((node) => node.id === childId) && !newSelection.includes(childId)) {
newSelection.push(childId)
}
})
}
}
})
this.selectedIds = newSelection
}
// Toggle node selection
toggleNodeSelection(nodeId: string, modules?: FlowModule[], nodes?: Node[]) {
const newSelection = [...this.selectedIds]
if (newSelection.includes(nodeId)) {
// Remove node
const index = newSelection.indexOf(nodeId)
newSelection.splice(index, 1)
// Also remove children
if (modules && nodes) {
const children = this.getNodeChildrenIds(nodeId, modules, nodes)
children.forEach((childId) => {
const childIndex = newSelection.indexOf(childId)
if (childIndex > -1) {
newSelection.splice(childIndex, 1)
}
})
}
} else {
// Add node
newSelection.push(nodeId)
// Auto-select children for hierarchical modules
if (modules && nodes) {
const children = this.getNodeChildrenIds(nodeId, modules, nodes)
children.forEach((childId) => {
if (!newSelection.includes(childId)) {
newSelection.push(childId)
}
})
}
}
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]
}
// Get primary selected node ID (for backwards compatibility)
get primarySelectedId(): string | undefined {
return this.getSelectedId()
}
// Handle keyboard shortcuts
handleKeyDown(event: KeyboardEvent, nodes?: Node[]) {
if (event.key === 'Escape') {
if (this.#selectionMode === 'rect-select') {
// Exit rect mode (this will preserve first item via mode setter)
this.mode = 'normal'
}
} 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)
}
}
}
}
@@ -8,7 +8,7 @@
import { nextId } from '../flows/flowModuleNextId'
const dispatch = createEventDispatcher()
const { flowStore, selectedId, flowStateStore } =
const { flowStore, selectionManager, flowStateStore } =
getContext<FlowEditorContext>('FlowEditorContext')
let tutorial: Tutorial | undefined = undefined
@@ -158,7 +158,7 @@
title: 'Step of the loop',
description: 'We added an action to the loop. Lets configure it',
onNextClick: () => {
$selectedId = tempId
selectionManager.selectId(tempId)
dispatch('reload')
setTimeout(() => {
+5 -3
View File
@@ -9,6 +9,7 @@
FlowInput,
FlowInputEditorState
} from '$lib/components/flows/types'
import { SelectionManager } from '$lib/components/graph/selectionUtils.svelte'
import { writable } from 'svelte/store'
import { OpenAPI, type OpenFlow, type TriggersCount } from '$lib/gen'
import { initHistory } from '$lib/history.svelte'
@@ -76,7 +77,8 @@
const history = initHistory(flowStore.val)
const stepsInputArgs = new StepsInputArgs()
const selectedIdStore = writable('settings-metadata')
const selectionManager = new SelectionManager()
selectionManager.selectId('settings-metadata')
const triggersCount = writable<TriggersCount | undefined>(undefined)
setContext<TriggerContext>('TriggerContext', {
triggersCount: triggersCount,
@@ -86,7 +88,7 @@
})
setContext<FlowEditorContext>('FlowEditorContext', {
selectedId: selectedIdStore,
selectionManager,
previewArgs: previewArgsStore,
scriptEditorDrawer,
moving,
@@ -293,7 +295,7 @@
on:applyArgs={(ev) => {
if (ev.detail.kind === 'preprocessor') {
stepsInputArgs.setStepArgs('preprocessor', ev.detail.args ?? {})
$selectedIdStore = 'preprocessor'
selectionManager.selectId('preprocessor')
} else {
previewArgsStore.val = ev.detail.args ?? {}
flowPreviewButtons?.openPreview()