Compare commits

...

2 Commits

Author SHA1 Message Date
Guilhem
a45759da3e fix: add missing freeDrag to MiniFlowGraph context
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:22:31 +00:00
Guilhem
5e0c93c7b2 feat: add free drag mode for flow editor nodes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:17:16 +00:00
10 changed files with 121 additions and 13 deletions

View File

@@ -13,6 +13,9 @@ export async function initFlow(
flowStateStore: StateStore<FlowState>
) {
await initFlowState(flow, flowStateStore)
if (flow.value && !flow.value.node_offsets) {
flow.value.node_offsets = {}
}
flowStore.val = flow
}

View File

@@ -137,6 +137,7 @@
const flowGraphContext = getGraphContext()
const diffManager = flowGraphContext?.diffManager
const moveManager = flowGraphContext?.moveManager
const freeDrag = flowGraphContext?.freeDrag
let pickableIds: Record<string, any> | undefined = $state(undefined)
@@ -321,7 +322,13 @@
style="width: 275px; height: 34px;"
onmouseenter={() => (hover = true)}
onmouseleave={() => (hover = false)}
onpointerdown={stopPropagation(preventDefault((e) => dispatch('pointerdown', e)))}
onpointerdown={(e) => {
if (!$freeDrag) {
e.stopPropagation()
e.preventDefault()
}
dispatch('pointerdown', e)
}}
>
{#if id}
<DiffActionBar moduleId={id} {moduleAction} {diffManager} {flowStore} />

View File

@@ -465,6 +465,18 @@
modules={flowStore.val.value.modules}
{noteMode}
notes={flowStore.val.value.notes}
nodeOffsets={flowStore.val.value.node_offsets}
onNodeOffsetUpdate={(nodeId, offset) => {
if (!flowStore.val.value.node_offsets) {
flowStore.val.value.node_offsets = {}
}
flowStore.val.value.node_offsets[nodeId] = offset
refreshStateStore(flowStore)
}}
onNodeOffsetsReset={() => {
flowStore.val.value.node_offsets = {}
refreshStateStore(flowStore)
}}
preprocessorModule={flowStore.val.value?.preprocessor_module}
failureModule={flowStore.val.value?.failure_module}
currentInputSchema={flowStore.val.schema}

View File

@@ -76,7 +76,7 @@ export function filteredContentForExport(flow: ExtendedOpenFlow) {
let o = {
summary: flow.summary,
description: flow.description,
value: flow.value,
value: $state.snapshot(flow.value),
schema: flow.schema
}
if (flow.dedicated_worker) {

View File

@@ -37,7 +37,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, MousePointer, Hand } from 'lucide-svelte'
import { Expand, MousePointer, Hand, RotateCcw } from 'lucide-svelte'
import Toggle from '../Toggle.svelte'
import DataflowEdge from './renderers/edges/DataflowEdge.svelte'
import { encodeState, readFieldsRecursively, getModifierKey, isMac } from '$lib/utils'
@@ -81,6 +81,7 @@
let useDataflow: Writable<boolean | undefined> = writable<boolean | undefined>(false)
let showAssets: Writable<boolean | undefined> = writable<boolean | undefined>(true)
let freeDrag: Writable<boolean> = writable<boolean>(false)
let showNotes = $state(true)
const triggerContext = getContext<TriggerContext>('TriggerContext')
@@ -129,6 +130,9 @@
suspendStatus?: Record<string, { job: Job; nb: number }>
noteMode?: boolean
notes?: FlowNote[]
nodeOffsets?: Record<string, { x: number; y: number }>
onNodeOffsetUpdate?: (nodeId: string, offset: { x: number; y: number }) => void
onNodeOffsetsReset?: () => void
chatInputEnabled?: boolean
multiSelectEnabled?: boolean
onDelete?: (id: string) => void
@@ -222,6 +226,9 @@
flowHasChanged = false,
noteMode = false,
notes = undefined,
nodeOffsets = undefined,
onNodeOffsetUpdate = undefined,
onNodeOffsetsReset = undefined,
exitNoteMode = undefined,
onNotePositionUpdate = undefined,
chatInputEnabled = false,
@@ -280,6 +287,7 @@
selectionManager: selectionManager,
useDataflow,
showAssets,
freeDrag,
noteManager,
moveManager,
clearFlowSelection,
@@ -514,6 +522,26 @@
let nodes = $state.raw<Node[]>([])
let edges = $state.raw<Edge[]>([])
// Track layout-computed positions (before user offsets) for drag offset computation
let layoutPositions: Record<string, { x: number; y: number }> = {}
const DRAGGABLE_NODE_TYPES = new Set([
'module',
'forLoopStart',
'forLoopEnd',
'branchAllStart',
'branchAllEnd',
'branchOneStart',
'branchOneEnd',
'whileLoopStart',
'whileLoopEnd',
'noBranch',
'subflowBound',
'input2',
'result',
'trigger'
])
let height = $state(0)
// Derived nodes with yOffset applied to all nodes uniformly and selectable flag set to false if notSelectable is true
@@ -642,6 +670,28 @@
}))
}
// Capture layout positions (after all spacing, before user offsets)
for (const n of finalNodes) {
if (n.type && DRAGGABLE_NODE_TYPES.has(n.type)) {
layoutPositions[n.id] = { x: n.position.x, y: n.position.y }
}
}
// Apply user-defined node offsets and set draggable flag
if (nodeOffsets || editMode) {
finalNodes = finalNodes.map((n) => {
const isDraggable = n.type != null && DRAGGABLE_NODE_TYPES.has(n.type)
const offset = nodeOffsets?.[n.id]
return {
...n,
position: offset
? { x: n.position.x + offset.x, y: n.position.y + offset.y }
: n.position,
draggable: isDraggable && editMode
}
})
}
// update nodes
nodes = [...finalNodes, ...(noteNodesResult?.noteNodes ?? [])]
@@ -764,9 +814,10 @@
$showAssets && Object.values(nodes).every((n) => n.type !== 'asset')
)
let hideNotesToggle = $derived(!notes || notes.length === 0)
let hasNodeOffsets = $derived(nodeOffsets && Object.keys(nodeOffsets).length > 0)
$effect(() => {
;[graph, allowSimplifiedPoll, $showAssets, showNotes, noteManager.renderCount]
;[graph, allowSimplifiedPoll, $showAssets, showNotes, noteManager.renderCount, nodeOffsets]
untrack(async () => {
await updateStores()
})
@@ -934,13 +985,23 @@
paneContextMenu?.onPaneContextMenu(event)
}}
onnodedragstop={(event) => {
const node = event.targetNode
if (node && node.type === 'note') {
const positionWithOffset = {
x: node.position.x,
y: node.position.y - yOffset
for (const node of event.nodes) {
if (node.type === 'note') {
const positionWithOffset = {
x: node.position.x,
y: node.position.y - yOffset
}
onNotePositionUpdate?.(node.id, positionWithOffset)
} else if (node.type && DRAGGABLE_NODE_TYPES.has(node.type)) {
const layoutPos = layoutPositions[node.id]
if (layoutPos && onNodeOffsetUpdate) {
const offset = {
x: node.position.x - layoutPos.x,
y: node.position.y - yOffset - layoutPos.y
}
onNodeOffsetUpdate(node.id, offset)
}
}
onNotePositionUpdate?.(node.id, positionWithOffset)
}
}}
onmove={(event, viewport) => {
@@ -967,7 +1028,7 @@
elevateNodesOnSelect={false}
{proOptions}
multiSelectionKey={'Shift'}
nodesDraggable={false}
nodesDraggable={$freeDrag || false}
--background-color={false}
>
<div class="absolute inset-0 !bg-surface-secondary h-full" id="flow-graph-v2"></div>
@@ -1067,6 +1128,18 @@
{#if showDataflow}
<Toggle bind:checked={$useDataflow} size="xs" options={{ right: 'Dataflow' }} />
{/if}
{#if editMode}
<Toggle bind:checked={$freeDrag} size="xs" options={{ right: 'Free drag' }} />
{/if}
{#if editMode && hasNodeOffsets}
<button
class="flex items-center gap-1 text-xs text-secondary hover:text-primary transition-colors"
onclick={() => onNodeOffsetsReset?.()}
>
<RotateCcw size={12} />
Reset layout
</button>
{/if}
</Controls>
{/if}
</SvelteFlow>

View File

@@ -38,6 +38,7 @@
selectionManager: new SelectionManager(),
useDataflow: writable(false),
showAssets: writable(false),
freeDrag: writable(false),
diffManager: createFlowDiffManager()
})

View File

@@ -9,6 +9,7 @@ export type GraphContext = {
selectionManager: SelectionManager
useDataflow: Writable<boolean | undefined>
showAssets: Writable<boolean | undefined>
freeDrag: Writable<boolean>
noteManager?: NoteManager
moveManager?: MoveManager
clearFlowSelection?: () => void

View File

@@ -63,7 +63,7 @@
const flowStore: StateStore<Flow> = $state({
val: {
summary: '',
value: { modules: [] },
value: { modules: [], node_offsets: {} },
path: '',
edited_at: '',
edited_by: '',

View File

@@ -55,7 +55,7 @@
export const flowStore: StateStore<Flow> = $state({
val: {
summary: '',
value: { modules: [] },
value: { modules: [], node_offsets: {} },
path: '',
edited_at: '',
edited_by: '',

View File

@@ -113,6 +113,17 @@ components:
description: Sticky notes attached to the flow
items:
$ref: '#/components/schemas/FlowNote'
node_offsets:
type: object
description: User-defined position offsets for flow nodes, keyed by module ID
additionalProperties:
type: object
properties:
x:
type: number
y:
type: number
required: [x, y]
required:
- modules