feat(frontend): add notes to flow (#6628)

* Add note component

* save note size and position

* move add note button up

* nit

* Add markdown support

* wip

* fix add sticky note button

* fix text update

* Add sticky note to saved flow data

* add note color picker

* Introduce node multiselect

* Add group notes

* Adapt layout to group node

* create a note manager class

* clean reactivity

* clean

* improve adaptive layout to group note

* modify layout based on cached text height

* fined grained graph rendering for notes

* separate noteManager into editor and render

* separate noteManager into editor and render

* create a note change observer

* render note node from context

* simplify note state managment

* show note in flow viewer

* clean dirty changes

* clean selection manager

* fix layout check

* improve bg surface select

* Handle z-index for stacked group notes

* clean selection manager

* exclude notes from rect select

* Allow switch between selection modes with keyboard keys

* improve selection box styling

* prevent dragging note when editing

* nit

* Simplify selection using svelte flow built in feature

* handle note selection separately

* Add min size for notes

* improve selection toggle

* improve mode switch

* make size and position optional for group notes

* Improve initial viewport position

* Add context menu for the canevas

* nit

* Add node context menu

* improve note select

* use clickoutside for note deselect

* use pointerdown outside to close context menu

* nit

* fix selection issues

* make edges non selectable

* improve color palette

* fix backend

* fix backend check

* cargo lock restore

* Add toggle to display notes

* fix note selection

* nit

* account for css offset in for loop

* fix multiple selection pannel styling

* clear flow selection when creating note

* Improve placeholder and note default text

* Escape note edit mode when pressing Esc

* Allow note edition in local dev

* clean

* Handle subflow selection

* prevent group note resizing

* nit

* allow notes in flow expand

* Improve multi select panel

* Allow context menu in note mode

* Add event listenner to fix pane click deselect

* prevent zoom in text area in notes

* improve bounding box styling

* Use control for box selection for non mac users

* nit

* clean notes groups

* nit

* use portal for note actions

* handle assets node when computing note layout

* Simplify layout compute for notes

* use smart color choice for notes

* Switch display note when adding a new note

* clean code

* improve group note bound size calculation

* simplify AI tool nodes and asset handling

* nit

* nit

* improve flow centering

* create group note button

* Improve selection of nodes

* Revert "Improve selection of nodes"

This reverts commit d2c40d82b1.

* refert backend changes

* nit

* improve graph selection

* clean

* make backend work except job runs

* fix notSelectable

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Guilhem
2025-11-19 21:25:57 +00:00
committed by GitHub
parent 180d9fc10d
commit 24f9115dc0
80 changed files with 3538 additions and 354 deletions
@@ -13,4 +13,4 @@
"nullable": []
},
"hash": "544a00afb5c72d2aa24ab4ccfe68d1e383aeda9a5a71183b7fbaa41deca4e333"
}
}
@@ -22,4 +22,4 @@
"nullable": []
},
"hash": "d2acc380c45df31f741bbd420c2f4a5b962f7803def9ecd6e33d2aec6abce772"
}
}
+2
View File
@@ -15510,6 +15510,8 @@ components:
$ref: "../../openflow.openapi.yaml#/components/schemas/FlowStatus"
FlowStatusModule:
$ref: "../../openflow.openapi.yaml#/components/schemas/FlowStatusModule"
FlowNote:
$ref: "../../openflow.openapi.yaml#/components/schemas/FlowNote"
# -- INLINE END --
# Do not change line above
+12 -10
View File
@@ -783,8 +783,8 @@ async fn update_flow(
sqlx::query!(
"
UPDATE
flow
UPDATE
flow
SET
path = $1,
summary = $2,
@@ -800,7 +800,7 @@ async fn update_flow(
schema = $9::text::json,
edited_by = $10,
edited_at = now()
WHERE
WHERE
path = $11 AND workspace_id = $12",
if is_new_path { flow_path } else { &nf.path },
nf.summary,
@@ -824,8 +824,8 @@ async fn update_flow(
if is_new_path {
// if new path, must clone flow to new path and delete old flow for flow_version foreign key constraint
sqlx::query!(
"INSERT INTO flow
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)
"INSERT INTO flow
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)
SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at
FROM flow
WHERE path = $2 AND workspace_id = $3",
@@ -893,6 +893,8 @@ async fn update_flow(
.warn_after_seconds(10)
.await??;
// tracing::error!("Updating flow: {:?}", nf.value.get());
// This will lock anyone who is trying to iterate on flow_versions with given path and parameters.
let version = sqlx::query_scalar!(
"INSERT INTO flow_version (workspace_id, path, value, schema, created_by) VALUES ($1, $2, $3, $4::text::json, $5) RETURNING id",
@@ -1143,11 +1145,11 @@ async fn get_flow_by_path(
favorite.path IS NOT NULL AS starred
FROM flow
LEFT JOIN favorite
ON favorite.favorite_kind = 'flow'
AND favorite.workspace_id = flow.workspace_id
AND favorite.path = flow.path
ON favorite.favorite_kind = 'flow'
AND favorite.workspace_id = flow.workspace_id
AND favorite.path = flow.path
AND favorite.usr = $3
LEFT JOIN flow_version
LEFT JOIN flow_version
ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.path = $1 AND flow.workspace_id = $2
"#,
@@ -1182,7 +1184,7 @@ async fn get_flow_by_path(
flow_version.created_by AS edited_by,
NULL AS starred
FROM flow
LEFT JOIN flow_version
LEFT JOIN flow_version
ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.path = $1 AND flow.workspace_id = $2
"#,
+15
View File
@@ -285,6 +285,21 @@ pub struct FlowData {
pub flow: FlowValue,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FlowNotes {
pub notes: Option<Box<RawValue>>,
}
impl FlowData {
pub fn notes(&self) -> Option<FlowNotes> {
serde_json::from_str::<FlowNotes>(self.raw_flow.get())
.map_err(|e| {
tracing::error!("Failed to parse notes into FlowNotes: {}", e);
error::Error::internal_err(format!("Failed to parse notes into FlowNotes: {}", e))
})
.ok()
}
}
/// !!!Shouldn't be used. Reverted optimization for ai agent steps.!!!
#[derive(Deserialize)]
struct RevertedFlowNodeFlow {
+1 -1
View File
@@ -194,7 +194,7 @@ pub struct FlowValue {
#[serde(skip_serializing_if = "Option::is_none")]
pub chat_input_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub flow_env: Option<HashMap<String, Box<RawValue>>>
pub flow_env: Option<HashMap<String, Box<RawValue>>>,
}
impl FlowValue {
@@ -9,6 +9,7 @@ use crate::scoped_dependency_map::ScopedDependencyMap;
use async_recursion::async_recursion;
use chrono::{Duration, Utc};
use itertools::Itertools;
use serde::Serialize;
use serde_json::value::RawValue;
use serde_json::{from_value, json, Value};
use sha2::Digest;
@@ -16,6 +17,7 @@ use sqlx::types::Json;
use tokio::time::timeout;
use uuid::Uuid;
use windmill_common::assets::{clear_asset_usage, insert_asset_usage, AssetUsageKind};
use windmill_common::cache::FlowNotes;
use windmill_common::error::Error;
use windmill_common::error::Result;
use windmill_common::flows::{FlowModule, FlowModuleValue, FlowNodeId};
@@ -765,15 +767,16 @@ pub async fn handle_flow_dependency_job(
// `JobKind::FlowDependencies` job store either:
// - A saved flow version `id` in the `script_hash` column.
// - Preview raw flow in the `queue` or `job` table.
let mut flow = match job.runnable_id {
Some(ScriptHash(id)) => cache::flow::fetch_version(db, id).await?,
let (mut flow, notes) = match job.runnable_id {
Some(ScriptHash(id)) => {
let flow = cache::flow::fetch_version(db, id).await?;
(flow.value().clone(), flow.notes())
}
_ => match preview_data {
Some(RawData::Flow(data)) => data.clone(),
Some(RawData::Flow(data)) => (data.value().clone(), data.notes()),
_ => return Err(Error::internal_err("expected script hash")),
},
}
.value()
.clone();
};
let mut tx = db.begin().await?;
@@ -861,7 +864,22 @@ pub async fn handle_flow_dependency_job(
.await?;
}
let new_flow_value = Json(serde_json::value::to_raw_value(&flow).map_err(to_anyhow)?);
#[derive(Debug, Clone, Serialize)]
struct FlowValueWithNotes<'a> {
#[serde(flatten)]
value: &'a FlowValue,
#[serde(skip_serializing_if = "Option::is_none")]
notes: Option<Box<RawValue>>, // TODO: Make this a Vec<FlowNote>
}
let new_flow_value = Json(
serde_json::value::to_raw_value(&FlowValueWithNotes {
value: &flow,
notes: notes.and_then(|n| n.notes).map(|n| n.into()),
})
.map_err(to_anyhow)?,
);
// Re-check cancellation to ensure we don't accidentally override a flow.
if sqlx::query_scalar!(
+37 -4
View File
@@ -17,7 +17,7 @@
"border-light": "#e5e7eb",
"border-normal": "#9ca3af",
"border-accent": "#2c5beb",
"surface-accent-selected": "#bfdbfe4c",
"surface-accent-selected": "#ebefff",
"surface-accent-secondary": "#293676",
"surface-tertiary": "#ffffff",
"text-emphasis": "#1d2430",
@@ -52,7 +52,7 @@
"border-light": "#485971",
"border-normal": "#718096",
"border-accent": "#a0affa",
"surface-accent-selected": "#6790c34c",
"surface-accent-selected": "#33384e",
"surface-accent-secondary": "#e8ebfb",
"surface-tertiary": "#434c5e",
"text-emphasis": "#f3f4f6",
@@ -87,7 +87,7 @@
"border-light": "#374457",
"border-normal": "#a9b0ba",
"border-accent": "#a0affa",
"surface-accent-selected": "#6790c44c",
"surface-accent-selected": "#33384e",
"surface-accent-secondary": "#e8ebfb",
"surface-tertiary": "#353c4a",
"text-emphasis": "#eeeff2",
@@ -193,7 +193,40 @@
"purple-800": "#483c60",
"purple-900": "#3a3549",
"purple-950": "#31313f",
"blue-950": "#213263"
"blue-950": "#213263",
"pink-50": "#fdf2f8",
"pink-100": "#fce7f3",
"pink-200": "#fbcfe8",
"pink-300": "#f9a8d4",
"pink-400": "#f472b6",
"pink-500": "#cc4e8c",
"pink-600": "#af4677",
"pink-700": "#8e4266",
"pink-800": "#5f3e52",
"pink-900": "#473340",
"pink-950": "#372b36",
"lime-50": "#f7fee7",
"lime-100": "#ecfccb",
"lime-200": "#d9f99d",
"lime-300": "#bef264",
"lime-400": "#a3e635",
"lime-500": "#84cc16",
"lime-600": "#5d8f16",
"lime-700": "#527029",
"lime-800": "#415824",
"lime-900": "#324220",
"lime-950": "#232f16",
"yellow-50": "#fefce8",
"yellow-100": "#fef9c3",
"yellow-200": "#fef08a",
"yellow-300": "#fde047",
"yellow-400": "#facc15",
"yellow-500": "#e0ae12",
"yellow-600": "#b1882e",
"yellow-700": "#8a6e31",
"yellow-800": "#61512d",
"yellow-900": "#443d22",
"yellow-950": "#3a351a"
}
},
"guidelines": { "mode-1": { "blue": "#5e81ac", "demo-background": "#ffffff00" } },
+18 -6
View File
@@ -31,6 +31,8 @@
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 { NoteEditor, setNoteEditorContext } from './graph/noteEditor.svelte'
import { dfs } from './flows/dfs'
import { loadSchemaFromModule } from './flows/flowInfers'
import { CornerDownLeft, Play } from 'lucide-svelte'
@@ -475,7 +477,7 @@
let ids = dfs(flowStore.val.value.modules ?? [], (m) => m.id)
flowStateStore.val = Object.fromEntries(ids.map((k) => [k, {}]))
} catch (e) {}
inferModuleArgs($selectedIdStore)
inferModuleArgs(selectedId)
}
} catch (e) {
console.error('issue setting new flowstore', e)
@@ -489,7 +491,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 +511,7 @@
let pathStore = writable('')
let initialPathStore = writable('')
setContext<FlowEditorContext>('FlowEditorContext', {
selectedId: selectedIdStore,
selectionManager,
previewArgs: previewArgsStore,
scriptEditorDrawer,
moving,
@@ -538,6 +541,13 @@
pickablePropertiesFiltered: writable<PickableProperties | undefined>(undefined)
})
// Set up NoteEditor context for note editing capabilities
const noteEditor = new NoteEditor(flowStore, () => {
// Enable notes display when a note is created
flowModuleSchemaMap?.enableNotes?.()
})
setNoteEditorContext(noteEditor)
let lastSent: OpenFlow | undefined = undefined
function updateFlow(flow: OpenFlow) {
if (lockChanges) {
@@ -618,7 +628,7 @@
flowStore.val && untrack(() => updateFlow(flowStore.val))
})
$effect(() => {
$selectedIdStore && untrack(() => inferModuleArgs($selectedIdStore))
selectedId && untrack(() => inferModuleArgs(selectedId))
})
let localModuleStates: Record<string, GraphModuleState> = $state({})
@@ -640,7 +650,7 @@
job.success &&
flowPreviewButtons?.getPreviewMode() === 'whole'
) {
if (flowModuleSchemaMap?.isNodeVisible('result') && $selectedIdStore !== 'Result') {
if (flowModuleSchemaMap?.isNodeVisible('result') && selectedId !== 'Result') {
outputPickerOpenFns['Result']?.()
}
} else {
@@ -665,6 +675,8 @@
}
const flowHasChanged = $derived(flowPreviewContent?.flowHasChanged())
const selectedId = $derived(selectionManager.getSelectedId())
</script>
<svelte:window onkeydown={onKeyDown} />
@@ -846,7 +858,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()
+36 -19
View File
@@ -43,6 +43,9 @@
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 { NoteEditor } from './graph/noteEditor.svelte'
import { setNoteEditorContext } from './graph/noteEditor.svelte'
import { cleanFlow } from './flows/utils.svelte'
import {
Calendar,
@@ -338,11 +341,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 +564,7 @@
encodeState({
flow: flowStore.val,
path: $pathStore,
selectedId: $selectedIdStore,
selectedId: selectedIdStore,
draft_triggers: triggersState.getDraftTriggersSnapshot(),
selected_trigger: triggersState.getSelectedTriggerSnapshot(),
loadedFromHistory: {
@@ -576,10 +579,17 @@
}, 500)
}
const selectedIdStore = writable<string>(selectedId ?? 'settings-metadata')
const selectionManager = new SelectionManager()
const selectedIdStore = $derived(selectionManager.getSelectedId())
// Initialize with selected id if provided
if (selectedId) {
selectionManager.selectId(selectedId)
} else {
selectionManager.selectId('settings-metadata')
}
export function getSelectedId() {
return $selectedIdStore
return selectedIdStore
}
const previewArgsStore = $state({ val: initialArgs })
@@ -598,7 +608,7 @@
const stepsInputArgs = new StepsInputArgs()
function select(selectedId: string) {
selectedIdStore.set(selectedId)
selectionManager.selectId(selectedId)
}
let insertButtonOpen = writable<boolean>(false)
@@ -607,7 +617,7 @@
let flowEditor: FlowEditor | undefined = $state(undefined)
setContext<FlowEditorContext>('FlowEditorContext', {
selectedId: selectedIdStore,
selectionManager,
currentEditor: writable(undefined),
previewArgs: previewArgsStore,
scriptEditorDrawer,
@@ -629,6 +639,13 @@
outputPickerOpenFns
})
// Set up NoteEditor context for note editing capabilities
const noteEditor = new NoteEditor(flowStore, () => {
// Enable notes display when a note is created
flowEditor?.enableNotes?.()
})
setNoteEditorContext(noteEditor)
setContext(
'FlowGraphAssetContext',
initFlowGraphAssetsCtx({ getModules: () => flowStore.val.value.modules })
@@ -695,7 +712,7 @@
case 'z':
if (event.ctrlKey || event.metaKey) {
flowStore.val = undo(history, flowStore.val)
$selectedIdStore = 'Input'
selectionManager.selectId('Input')
event.preventDefault()
}
break
@@ -708,9 +725,9 @@
case 'ArrowDown': {
if (!$insertButtonOpen && !flowPreviewButtons?.getPreviewOpen()) {
let ids = generateIds()
let idx = ids.indexOf($selectedIdStore)
let idx = ids.indexOf(selectedIdStore!)
if (idx > -1 && idx < ids.length - 1) {
$selectedIdStore = ids[idx + 1]
selectionManager.selectId(ids[idx + 1])
event.preventDefault()
}
}
@@ -719,9 +736,9 @@
case 'ArrowUp': {
if (!$insertButtonOpen && !flowPreviewButtons?.getPreviewOpen()) {
let ids = generateIds()
let idx = ids.indexOf($selectedIdStore)
let idx = ids.indexOf(selectedIdStore!)
if (idx > 0 && idx < ids.length) {
$selectedIdStore = ids[idx - 1]
selectionManager.selectId(ids[idx - 1])
event.preventDefault()
}
}
@@ -868,7 +885,7 @@
setContext('customUi', customUi)
})
$effect.pre(() => {
if (flowStore.val || $selectedIdStore) {
if (flowStore.val || selectedIdStore) {
readFieldsRecursively(flowStore.val)
untrack(() => saveSessionDraft())
}
@@ -932,7 +949,7 @@
job.success &&
flowPreviewButtons?.getPreviewMode() === 'whole'
) {
if (flowEditor?.isNodeVisible('result') && $selectedIdStore !== 'Result') {
if (flowEditor?.isNodeVisible('result') && selectedIdStore !== 'Result') {
outputPickerOpenFns['Result']?.()
}
} else {
@@ -1026,7 +1043,7 @@
}
}
$selectedIdStore = 'Input'
selectionManager.selectId('Input')
}}
on:redo={() => {
flowStore.val = redo(history)
@@ -1044,7 +1061,7 @@
variant="subtle"
size="xs"
on:click={async () => {
select('triggers')
select('Trigger')
const selected = primaryScheduleIndex ?? scheduleIndex
if (selected) {
triggersState.selectedTriggerIndex = selected
@@ -1137,7 +1154,7 @@
{/if}
<FlowPreviewButtons
on:openTriggers={(e) => {
select('triggers')
select('Trigger')
handleSelectTriggerFromKind(triggersState, triggersCount, initialPath, e.detail.kind)
captureOn.set(true)
showCaptureHint.set(true)
@@ -1190,7 +1207,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 +1220,7 @@
{savedFlow}
onDeployTrigger={handleDeployTrigger}
onEditInput={(moduleId, key) => {
selectedIdStore.set(moduleId)
selectionManager.selectId(moduleId)
// Use new prop-based system
forceTestTab[moduleId] = true
highlightArg[moduleId] = key
@@ -45,8 +45,9 @@
modules={flow?.value?.modules}
failureModule={flow?.value?.failure_module}
preprocessorModule={flow?.value?.preprocessor_module}
notes={flow?.value?.notes}
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,
@@ -136,7 +136,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)
@@ -441,7 +441,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}
@@ -1773,6 +1773,7 @@
earlyStop={job.raw_flow?.skip_expr !== undefined}
cache={job.raw_flow?.cache_ttl !== undefined}
modules={job.raw_flow?.modules ?? []}
notes={job.raw_flow?.notes ?? []}
failureModule={job.raw_flow?.failure_module}
preprocessorModule={job.raw_flow?.preprocessor_module}
allowSimplifiedPoll={false}
@@ -3,7 +3,12 @@
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import rehypeRaw from 'rehype-raw'
import { rehypeGithubAlerts } from 'rehype-github-alerts'
export let md: string
interface Props {
md: string
noPadding?: boolean
}
let { md, noPadding }: Props = $props()
const plugins: Plugin[] = [
gfmPlugin(),
{ rehypePlugin: [rehypeRaw] },
@@ -11,7 +16,7 @@
]
</script>
<div class="!prose-xs pgap">
<div class="!prose-xs {noPadding ? '' : 'pgap'}">
<Markdown {md} {plugins} />
</div>
@@ -215,7 +215,7 @@
const horizontalPadding = iconOnly
? ButtonType.UnifiedIconOnlySizingClasses[unifiedSize]
: ButtonType.UnifiedSizingClasses[unifiedSize]
const height = ButtonType.UnifiedMinHeightClasses[unifiedSize]
const height = ButtonType.UnifiedHeightClasses[unifiedSize]
return `${horizontalPadding} ${height}`
}
@@ -17,7 +17,7 @@ export namespace ButtonType {
* @deprecated Use `UnifiedSize` instead
*/
export type Size = 'xs3' | 'xs2' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
export type UnifiedSize = 'sm' | 'md' | 'lg'
export type UnifiedSize = 'xs' | 'sm' | 'md' | 'lg'
export type ExtendedSize = 'xs2' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
/**
* @deprecated Use `Variant` instead
@@ -194,7 +194,7 @@ export namespace ButtonType {
accent:
'bg-red-500 dark:bg-red-600 hover:bg-red-600 dark:hover:bg-red-700 focus-visible:bg-red-700 text-white focus-visible:ring-red-300',
default:
'border border-border-light bg-transparent hover:bg-red-500 dark:hover:bg-red-600 hover:text-white dark:hover:bg-red-900/20 text-primary focus-visible:bg-red-100 dark:focus-visible:bg-red-900/30 focus-visible:ring-red-300',
'border border-border-light bg-transparent hover:bg-red-500 dark:hover:bg-red-600 hover:text-white dark:hover:bg-red-600 text-primary focus-visible:bg-red-100 dark:focus-visible:bg-red-900/30 focus-visible:ring-red-300',
subtle:
'bg-transparent hover:bg-red-500 hover:text-white dark:hover:bg-red-600 text-primary focus-visible:bg-red-100 dark:focus-visible:bg-red-900/30 focus-visible:ring-red-300'
}
@@ -221,36 +221,42 @@ export namespace ButtonType {
// New unified sizing system
export const UnifiedSizingClasses: Record<ButtonType.UnifiedSize, string> = {
xs: 'px-1',
sm: 'px-2', // Regular horizontal padding
md: 'px-4',
lg: 'px-6'
}
export const UnifiedIconOnlySizingClasses: Record<ButtonType.UnifiedSize, string> = {
xs: 'px-1',
sm: 'px-2', // Square padding for icon-only (same as width padding)
md: 'px-2',
lg: 'px-4'
}
export const UnifiedMinHeightClasses: Record<ButtonType.UnifiedSize, string> = {
xs: 'min-h-5',
sm: 'min-h-7',
md: 'min-h-8',
lg: 'min-h-10'
}
export const UnifiedHeightClasses: Record<ButtonType.UnifiedSize, string> = {
xs: 'h-5',
sm: 'h-7',
md: 'h-8',
lg: 'h-10'
}
export const UnifiedIconSizes: Record<ButtonType.UnifiedSize, number> = {
xs: 12,
sm: 13,
md: 14,
lg: 18
}
export const UnifiedFontSizes: Record<ButtonType.UnifiedSize, string> = {
xs: 'font-normal',
sm: 'font-normal',
md: 'font-medium',
lg: 'font-medium'
@@ -0,0 +1,126 @@
<script lang="ts">
import { createContextMenu, melt } from '@melt-ui/svelte'
import { fly } from 'svelte/transition'
import { twMerge } from 'tailwind-merge'
import type { Snippet } from 'svelte'
import { pointerDownOutside } from '$lib/utils'
import {
getContextMenuContainerClass,
CONTEXT_MENU_ITEM_BASE_CLASS,
CONTEXT_MENU_ITEM_HOVER_MELT_CLASS,
CONTEXT_MENU_ITEM_DISABLED_CLASS,
CONTEXT_MENU_DIVIDER_CLASS,
CONTEXT_MENU_ANIMATION_CLASSES
} from './contextMenuStyles'
export interface ContextMenuItem {
id: string
label: string
icon?: any
disabled?: boolean
onClick?: () => void
divider?: boolean
}
interface Props {
items?: ContextMenuItem[]
children?: Snippet
menu?: Snippet<[{ item: ContextMenuItem }]>
class?: string
onItemClick?: (item: ContextMenuItem) => void
closeOnOutsideClick?: boolean
}
let {
items = [],
children,
menu,
class: className = '',
onItemClick,
closeOnOutsideClick = true
}: Props = $props()
const {
elements: { menu: menuElement, item, trigger },
states: { open }
} = createContextMenu({
positioning: {
placement: 'right-start'
},
preventScroll: true,
closeOnOutsideClick: false
})
function handleItemClick(menuItem: ContextMenuItem) {
if (!menuItem.disabled) {
menuItem.onClick?.()
onItemClick?.(menuItem)
}
}
function close() {
open.set(false)
}
async function getMenuElements(): Promise<HTMLElement[]> {
return Array.from(document.querySelectorAll('[data-context-menu]')) as HTMLElement[]
}
function handlePointerDownOutside() {
if (closeOnOutsideClick && open.get()) {
close()
}
}
</script>
<div
use:melt={$trigger}
class={twMerge('block w-full h-full', className)}
role="button"
tabindex="0"
aria-label="Right click for context menu"
use:pointerDownOutside={{
capture: true,
stopPropagation: false,
exclude: getMenuElements,
onClickOutside: handlePointerDownOutside
}}
data-context-menu-trigger
>
{@render children?.()}
</div>
{#if $open}
<div
class="{getContextMenuContainerClass()} {CONTEXT_MENU_ANIMATION_CLASSES}"
use:melt={$menuElement}
transition:fly={{ duration: 150, y: -10 }}
data-context-menu
>
{#each items as menuItem (menuItem.id)}
{#if menuItem.divider}
<div class={CONTEXT_MENU_DIVIDER_CLASS}></div>
{:else}
<div
class={twMerge(
CONTEXT_MENU_ITEM_BASE_CLASS,
menuItem.disabled
? CONTEXT_MENU_ITEM_DISABLED_CLASS
: CONTEXT_MENU_ITEM_HOVER_MELT_CLASS
)}
use:melt={$item}
onclick={() => handleItemClick(menuItem)}
>
{#if menuItem.icon}
<menuItem.icon size={14} class="mr-2" />
{/if}
{#if menu}
{@render menu({ item: menuItem })}
{:else}
<span>{menuItem.label}</span>
{/if}
</div>
{/if}
{/each}
</div>
{/if}
@@ -0,0 +1,45 @@
/**
* Shared styles for context menu components
* Ensures visual consistency across all context menu implementations
*/
/**
* Base container styles for context menu
* @param zIndex - Optional z-index override (default: 'z-50')
*/
export function getContextMenuContainerClass(zIndex: string = 'z-50'): string {
return `${zIndex} flex flex-col gap-1 min-w-[12rem] overflow-hidden rounded-md border bg-surface p-1 shadow-md`
}
/**
* Base styles for context menu items
*/
export const CONTEXT_MENU_ITEM_BASE_CLASS =
'relative flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-xs outline-none transition-colors'
/**
* Hover state styles for context menu items (standard CSS hover)
*/
export const CONTEXT_MENU_ITEM_HOVER_CLASS = 'hover:bg-surface-hover'
/**
* Hover state styles for context menu items (Melt UI data attribute)
*/
export const CONTEXT_MENU_ITEM_HOVER_MELT_CLASS = 'data-[highlighted]:bg-surface-hover'
/**
* Disabled state styles for context menu items
*/
export const CONTEXT_MENU_ITEM_DISABLED_CLASS = 'pointer-events-none opacity-50'
/**
* Divider styles for context menu
*/
export const CONTEXT_MENU_DIVIDER_CLASS = 'my-1 h-px bg-border-light'
/**
* Melt UI animation classes for context menu
*/
export const CONTEXT_MENU_ANIMATION_CLASSES =
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2'
@@ -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(
@@ -882,7 +882,7 @@ class AIChatManager {
}
listenForSelectedIdChanges = (
selectedId: string,
selectedId: string | undefined,
flowStore: ExtendedOpenFlow,
flowStateStore: FlowState,
currentEditor: CurrentEditor
@@ -25,8 +25,9 @@
flowModuleSchemaMap: FlowModuleSchemaMap | undefined
} = $props()
const { flowStore, flowStateStore, selectedId, currentEditor } =
const { flowStore, flowStateStore, selectionManager, currentEditor } =
getContext<FlowEditorContext>('FlowEditorContext')
const selectedId = $derived(selectionManager.getSelectedId())
const { exprsToSet } = getContext<FlowCopilotContext | undefined>('FlowCopilotContext') ?? {}
@@ -84,7 +85,7 @@
const flow = $state.snapshot(flowStore).val
return {
flow,
selectedId: $selectedId
selectedId: selectedId
}
},
// flow apply/reject
@@ -382,7 +383,7 @@
value: match[2].trim()
}))
if (id === $selectedId) {
if (id === selectedId) {
exprsToSet?.set({})
const argsToUpdate = {}
for (const { input, value } of parsedInputs) {
@@ -421,7 +422,7 @@
setModuleStatus('Input', 'modified')
},
selectStep: (id) => {
$selectedId = id
selectionManager.selectId(id)
},
getStepCode: (id) => {
const module = getModule(id)
@@ -611,7 +612,7 @@
$effect(() => {
const cleanup = aiChatManager.listenForSelectedIdChanges(
$selectedId,
selectedId,
flowStore.val,
flowStateStore.val,
$currentEditor
@@ -628,19 +629,18 @@
$effect(() => {
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()
}
})
@@ -102,6 +102,10 @@
return flowModuleSchemaMap?.isNodeVisible(nodeId) ?? false
}
export function enableNotes(): void {
flowModuleSchemaMap?.enableNotes?.()
}
setContext<PropPickerContext>('PropPickerContext', {
flowPropPickerConfig: writable<FlowPropPickerConfig | undefined>(undefined),
pickablePropertiesFiltered: writable<PickableProperties | undefined>(undefined)
@@ -15,6 +15,7 @@
import { computeMissingInputWarnings } from '../missingInputWarnings'
import FlowResult from './FlowResult.svelte'
import type { StateStore } from '$lib/utils'
import FlowSelectionPanel from './FlowSelectionPanel.svelte'
interface Props {
noEditor?: boolean
@@ -55,7 +56,7 @@
}: Props = $props()
const {
selectedId,
selectionManager,
flowStore,
flowStateStore,
flowInputsStore,
@@ -66,6 +67,8 @@
flowInputEditorState
} = getContext<FlowEditorContext>('FlowEditorContext')
const selectedId = $derived(selectionManager.getSelectedId())
const { showCaptureHint, triggersState, triggersCount } =
getContext<TriggerContext>('TriggerContext')
function checkDup(modules: FlowModule[]): string | undefined {
@@ -84,14 +87,16 @@
})
</script>
{#if $selectedId?.startsWith('settings')}
{#if selectionManager && selectionManager.selectedIds.length > 1}
<FlowSelectionPanel {selectionManager} {noEditor} />
{:else if selectedId?.startsWith('settings')}
<FlowSettings {enableAi} {noEditor} />
{:else if $selectedId === 'Input'}
{:else if selectedId === '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 +104,22 @@
{onTestFlow}
{previewOpen}
/>
{:else if $selectedId === 'Result'}
{:else if selectedId === 'Result'}
<FlowResult {noEditor} {job} {isOwner} {suspendStatus} {onOpenDetails} />
{:else if $selectedId === 'constants'}
{:else if selectedId === 'constants'}
<FlowEnvironmentVariables {noEditor} />
{:else if $selectedId === 'failure'}
{:else if selectedId === 'failure'}
<FlowFailureModule {noEditor} savedModule={savedFlow?.value.failure_module} />
{:else if $selectedId === 'preprocessor'}
{:else if selectedId === 'preprocessor'}
<FlowPreprocessorModule {noEditor} savedModule={savedFlow?.value.preprocessor_module} />
{:else if $selectedId === 'triggers'}
{:else if selectedId === '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 +127,7 @@
previewArgs.val = JSON.parse(JSON.stringify(payloadData))
}
if (redirect) {
$selectedId = 'Input'
selectionManager.selectId('Input')
$flowInputEditorState.selectedTab = 'captures'
$flowInputEditorState.payloadData = payloadData
}
@@ -141,7 +146,7 @@
schema={flowStore.val.schema}
{onDeployTrigger}
/>
{:else if $selectedId.startsWith('subflow:')}
{:else if selectedId?.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 +155,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 selectedId}
{#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,
@@ -70,6 +70,8 @@
executionCount
} = getContext<FlowEditorContext>('FlowEditorContext')
const selectedId = $derived(selectionManager.getSelectedId())
interface Props {
flowModule: FlowModule
failureModule?: boolean
@@ -215,7 +217,7 @@
let stepHistoryLoader = getStepHistoryLoaderContext()
function onSelectedIdChange() {
if (!flowStateStore?.val?.[$selectedId]?.schema && flowModule) {
if (!flowStateStore?.val?.[selectedId]?.schema && flowModule) {
reload(flowModule)
}
}
@@ -252,7 +254,7 @@
)
$effect.pre(() => {
$selectedId && untrack(() => onSelectedIdChange())
selectedId && untrack(() => onSelectedIdChange())
})
let parentLoop = $derived(
flowStore.val && flowModule ? checkIfParentLoop(flowStore.val, flowModule.id) : undefined
@@ -404,7 +406,7 @@
on:createScriptFromInlineScript={async () => {
const [module, state] = await createScriptFromInlineScript(
flowModule,
$selectedId,
selectedId,
flowStateStore.val[flowModule.id].schema,
$pathStore
)
@@ -468,7 +470,7 @@
automaticLayout={true}
cmdEnterAction={async () => {
selected = 'test'
if ($selectedId == flowModule.id) {
if (selectedId == flowModule.id) {
if (flowModule.value.type === 'rawscript' && editor) {
flowModule.value.content = editor.getCode()
}
@@ -578,7 +580,7 @@
class="px-2 xl:px-4"
bind:this={inputTransformSchemaForm}
pickableProperties={stepPropPicker.pickableProperties}
schema={flowStateStore.val[$selectedId]?.schema ?? {}}
schema={flowStateStore.val[selectedId]?.schema ?? {}}
previousModuleId={previousModule?.id}
bind:args={
() => {
@@ -609,7 +611,7 @@
bind:this={modulePreview}
mod={flowModule}
{noEditor}
schema={flowStateStore.val[$selectedId]?.schema ?? {}}
schema={flowStateStore.val[selectedId]?.schema ?? {}}
bind:testJob
bind:testIsLoading
bind:scriptProgress
@@ -623,7 +625,7 @@
active={flowModule.retry !== undefined}
label="Retries"
/>
{#if !$selectedId.includes('failure')}
{#if !selectedId.includes('failure')}
<Tab value="runtime" label="Runtime" />
<Tab value="cache" active={Boolean(flowModule.cache_ttl)} label="Cache" />
<Tab
@@ -838,7 +840,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,8 @@
import { formatCron } from '$lib/utils'
import AgentToolWrapper from './AgentToolWrapper.svelte'
const { selectedId, flowStateStore } = getContext<FlowEditorContext>('FlowEditorContext')
const { selectionManager, flowStateStore } = getContext<FlowEditorContext>('FlowEditorContext')
const selectedId = $derived(selectionManager.getSelectedId())
const { triggersState, triggersCount } = getContext<TriggerContext>('TriggerContext')
@@ -113,7 +114,7 @@
}
</script>
{#if flowModule.id === $selectedId}
{#if flowModule.id === selectedId}
{#if flowModule.value.type === 'forloopflow'}
<FlowLoop {noEditor} bind:mod={flowModule} {parentModule} {previousModule} {enableAi} />
{:else if flowModule.value.type === 'whileloopflow'}
@@ -123,13 +124,13 @@
{:else if flowModule.value.type === 'branchall'}
<FlowBranchesAllWrapper {noEditor} {previousModule} {parentModule} bind:flowModule />
{:else if flowModule.value.type === 'identity'}
{#if $selectedId == 'failure'}
{#if selectedId == '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 selectedId == 'preprocessor'}
<div class="p-4">
<Alert
type="info"
@@ -157,8 +158,8 @@
summary={flowModule.summary}
shouldDisableTriggerScripts={parentModule !== undefined ||
previousModule !== undefined ||
$selectedId == 'failure' ||
$selectedId == 'preprocessor'}
selectedId == 'failure' ||
selectedId == 'preprocessor'}
on:pick={async ({ detail }) => {
const { path, summary, kind, hash } = detail
createModuleFromScript(path, summary, kind, hash)
@@ -187,8 +188,8 @@
flowModule = module
flowStateStore.val[module.id] = state
}}
failureModule={$selectedId === 'failure'}
preprocessorModule={$selectedId === 'preprocessor'}
failureModule={selectedId === 'failure'}
preprocessorModule={selectedId === 'preprocessor'}
/>
{/if}
{:else if flowModule.value.type === 'rawscript' || flowModule.value.type === 'script' || flowModule.value.type === 'flow' || flowModule.value.type === 'aiagent'}
@@ -197,8 +198,8 @@
bind:flowModule
{parentModule}
{previousModule}
failureModule={$selectedId === 'failure'}
preprocessorModule={$selectedId === 'preprocessor'}
failureModule={selectedId === 'failure'}
preprocessorModule={selectedId === 'preprocessor'}
{scriptKind}
{scriptTemplate}
{enableAi}
@@ -225,7 +226,7 @@
/>
{/each}
{:else if flowModule.value.type === 'branchone'}
{#if $selectedId === `${flowModule?.id}-branch-default`}
{#if selectedId === `${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 +248,7 @@
{/each}
{/if}
{#each flowModule.value.branches as branch, branchIndex (branchIndex)}
{#if $selectedId === `${flowModule?.id}-branch-${branchIndex}`}
{#if selectedId === `${flowModule?.id}-branch-${branchIndex}`}
<FlowBranchOneWrapper
{noEditor}
bind:branch={flowModule.value.branches[branchIndex]}
@@ -274,7 +275,7 @@
{/each}
{:else if flowModule.value.type === 'branchall'}
{#each flowModule.value.branches as branch, branchIndex (branchIndex)}
{#if $selectedId === `${flowModule?.id}-branch-${branchIndex}`}
{#if selectedId === `${flowModule?.id}-branch-${branchIndex}`}
<FlowBranchAllWrapper {noEditor} bind:branch={flowModule.value.branches[branchIndex]} />
{:else}
{#each branch.modules as _, index}
@@ -295,7 +296,7 @@
{/each}
{:else if flowModule.value.type === 'aiagent'}
{#each flowModule.value.tools as tool, toolIndex (toolIndex)}
{#if $selectedId === tool.id}
{#if selectedId === tool.id}
<AgentToolWrapper
{noEditor}
bind:tool={flowModule.value.tools[toolIndex]}
@@ -0,0 +1,44 @@
<script lang="ts">
import FlowCard from '../common/FlowCard.svelte'
import type { SelectionManager } from '$lib/components/graph/selectionUtils.svelte'
import { Button } from '$lib/components/common'
import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte'
import { StickyNote } from 'lucide-svelte'
interface Props {
selectionManager: SelectionManager
noEditor: boolean
}
let { selectionManager, noEditor }: Props = $props()
const noteEditorContext = getNoteEditorContext()
function addGroupNote() {
if (selectionManager.selectedIds.length > 0 && noteEditorContext?.noteEditor) {
// Create the group note
noteEditorContext.noteEditor.createGroupNote(selectionManager.selectedIds)
}
}
</script>
<FlowCard {noEditor} title="Multiple Selection">
{#snippet action()}
<Button
onClick={addGroupNote}
disabled={!noteEditorContext?.noteEditor || selectionManager.selectedIds.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>
<div class="space-y-2 mb-4">
{#each selectionManager.selectedIds as nodeId}
<div class="text-sm px-2 py-1 bg-surface rounded border">
{nodeId}
</div>
{/each}
</div>
</div>
</FlowCard>
@@ -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} />
@@ -281,7 +281,7 @@
style="width: 275px; height: 34px;"
onmouseenter={() => (hover = true)}
onmouseleave={() => (hover = false)}
onpointerdown={stopPropagation(preventDefault(() => dispatch('pointerdown')))}
onpointerdown={stopPropagation(preventDefault((e) => dispatch('pointerdown', e)))}
>
{#if deletable}
<ModuleAcceptReject action={moduleAction ?? action} {id} />
@@ -42,6 +42,7 @@
import { ModulesTestStates } from '$lib/components/modulesTest.svelte'
import type { StateStore } from '$lib/utils'
import { type AgentTool, flowModuleToAgentTool, createMcpTool } from '../agentToolUtils'
import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte'
interface Props {
sidebarSize?: number | undefined
@@ -105,12 +106,15 @@
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')
const { flowPropPickerConfig } = getContext<PropPickerContext>('PropPickerContext')
// Get NoteEditor context for note position updates
const noteEditorContext = getNoteEditorContext()
export async function insertNewModuleAtIndex(
modules: FlowModule[] | AgentTool[],
index: number,
@@ -238,9 +242,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')
}
}
}
@@ -290,10 +294,19 @@
let dependents: Record<string, string[]> = $state({})
let graph: FlowGraphV2 | undefined = $state(undefined)
let noteMode = $state(false)
export function isNodeVisible(nodeId: string): boolean {
return graph?.isNodeVisible(nodeId) ?? false
}
export function enableNotes(): void {
graph?.enableNotes?.()
}
function toggleNoteMode() {
noteMode = !noteMode
}
function shouldRunTutorial(tutorialName: string, name: string, index: number) {
return (
$tutorialsToDo.includes(index) &&
@@ -400,6 +413,8 @@
on:generateStep
{aiChatOpen}
{toggleAiChat}
{noteMode}
{toggleNoteMode}
/>
</div>
@@ -418,8 +433,10 @@
moving={$moving?.id}
maxHeight={minHeight}
modules={flowStore.val.value.modules}
{noteMode}
notes={flowStore.val.value.notes}
preprocessorModule={flowStore.val.value?.preprocessor_module}
{selectedId}
{selectionManager}
{workspace}
editMode
{onTestUpTo}
@@ -438,7 +455,7 @@
const cb = () => {
push(history, flowStore.val)
if (id === 'preprocessor') {
$selectedId = 'Input'
selectionManager.selectId('Input')
flowStore.val.value.preprocessor_module = undefined
} else {
selectNextId(id)
@@ -497,7 +514,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) {
@@ -507,7 +524,7 @@
detail.inlineScript,
detail.script
)
$selectedId = 'preprocessor'
selectionManager.selectId('preprocessor')
if (detail.inlineScript?.instructions) {
dispatch('generateStep', {
@@ -534,7 +551,7 @@
toolKind
)
const id = targetModules[index].id
$selectedId = id
selectionManager.selectId(id)
if (detail.inlineScript?.instructions) {
dispatch('generateStep', {
@@ -619,13 +636,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) => {
@@ -645,6 +662,14 @@
{onCancelTestFlow}
{onOpenPreview}
{onHideJobStatus}
exitNoteMode={() => (noteMode = false)}
onNotePositionUpdate={(noteId, position) => {
// Update note position via NoteEditor context in edit mode
if (noteEditorContext?.noteEditor) {
noteEditorContext.noteEditor.updatePosition(noteId, position)
}
}}
multiSelectEnabled
/>
</div>
</div>
@@ -2,7 +2,7 @@
import type { FlowEditorContext } from '../types'
import { getContext } from 'svelte'
import { Badge } from '$lib/components/common'
import { DollarSign, Settings } from 'lucide-svelte'
import { DollarSign, Settings, StickyNote } from 'lucide-svelte'
import FlowErrorHandlerItem from './FlowErrorHandlerItem.svelte'
import FlowAIButton from '$lib/components/copilot/chat/flow/FlowAIButton.svelte'
import Popover from '$lib/components/Popover.svelte'
@@ -15,6 +15,8 @@
aiChatOpen?: boolean
showFlowAiButton?: boolean
toggleAiChat?: () => void
noteMode?: boolean
toggleNoteMode?: () => void
disableAi?: boolean
}
@@ -25,10 +27,13 @@
aiChatOpen,
showFlowAiButton,
toggleAiChat,
noteMode,
toggleNoteMode,
disableAi
}: Props = $props()
const { selectedId, flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
const { selectionManager, flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
const selectedId = $derived(selectionManager.getSelectedId())
</script>
<div class="flex flex-row gap-2 p-1 rounded-md bg-surface">
@@ -37,10 +42,10 @@
unifiedSize="sm"
wrapperClasses="min-w-36"
startIcon={{ icon: Settings }}
selected={$selectedId?.startsWith('settings')}
selected={selectedId?.startsWith('settings')}
variant="default"
title="Settings"
onClick={() => ($selectedId = 'settings')}
onClick={() => selectionManager.selectId('settings')}
>
Settings
{#if flowStore.val.value.same_worker}
@@ -60,10 +65,10 @@
wrapperClasses="h-full"
unifiedSize="sm"
startIcon={{ icon: DollarSign }}
selected={$selectedId === 'constants'}
selected={selectedId === 'constants'}
variant="default"
iconOnly
onClick={() => ($selectedId = 'constants')}
onClick={() => selectionManager.selectId('constants')}
/>
{#snippet text()}
Environment Variables
@@ -83,4 +88,17 @@
{/snippet}
</Popover>
{/if}
<Popover>
<Button
onclick={() => toggleNoteMode?.()}
iconOnly
variant="default"
unifiedSize="sm"
startIcon={{ icon: StickyNote }}
selected={noteMode}
></Button>
{#snippet text()}
{noteMode ? 'Exit note mode' : 'Add sticky notes'}
{/snippet}
</Popover>
</div>
@@ -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,7 @@
}>()
let itemProps = $derived({
selected: $selectedId === mod.id,
selected: selectionManager && selectionManager.isNodeSelected(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),
@@ -102,6 +100,13 @@
let parentLoop = $derived(
flowStore?.val && mod ? checkIfParentLoop(flowStore.val, mod.id) : undefined
)
function handlePointerDown(e: CustomEvent<PointerEvent>) {
// Only handle left clicks (button 0)
if (e.detail.button === 0) {
onSelect(mod.id)
}
}
</script>
{#if mod}
@@ -164,7 +169,7 @@
on:changeId
on:move
on:delete
on:pointerdown={() => onSelect(mod.id)}
on:pointerdown={handlePointerDown}
onUpdateMock={(mock) => {
mod.mock = mock
onUpdateMock?.({ id: mod.id, mock })
@@ -193,7 +198,7 @@
on:changeId
on:delete
on:move
on:pointerdown={() => onSelect(mod.id)}
on:pointerdown={handlePointerDown}
{...itemProps}
id={mod.id}
label={mod.summary || 'Run one branch'}
@@ -213,7 +218,7 @@
on:changeId
on:delete
on:move
on:pointerdown={() => onSelect(mod.id)}
on:pointerdown={handlePointerDown}
id={mod.id}
{...itemProps}
label={mod.summary || `Run all branches${mod.value.parallel ? ' (parallel)' : ''}`}
@@ -231,7 +236,7 @@
{moduleAction}
{onShowModuleDiff}
on:changeId
on:pointerdown={() => onSelect(mod.id)}
on:pointerdown={handlePointerDown}
on:delete
on:move
onUpdateMock={(mock) => {
+4 -1
View File
@@ -15,6 +15,8 @@ import type ResourceEditorDrawer from '../ResourceEditorDrawer.svelte'
import type { ModulesTestStates } from '../modulesTest.svelte'
import type { ButtonProp } from '$lib/components/DiffEditor.svelte'
import type { SelectionManager } from '../graph/selectionUtils.svelte'
export type FlowInput = Record<
string,
{
@@ -28,6 +30,7 @@ export type FlowInput = Record<
}
>
// Extended OpenFlow with additional properties not in the core spec
export type ExtendedOpenFlow = OpenFlow & {
tag?: string
ws_error_handler_muted?: boolean
@@ -68,7 +71,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,7 +1,7 @@
<script lang="ts">
import { FlowService, type FlowModule, type Job } from '../../gen'
import { NODE, type GraphModuleState } from '.'
import { getContext, onDestroy, setContext, tick, untrack, type Snippet } from 'svelte'
import { FlowService, type FlowModule, type FlowNote, type Job } from '../../gen'
import { AI_OR_ASSET_NODE_TYPES, NODE, type GraphModuleState } from '.'
import { getContext, onDestroy, onMount, tick, untrack, type Snippet } from 'svelte'
import { get, writable, type Writable } from 'svelte/store'
import '@xyflow/svelte/dist/base.css'
@@ -13,7 +13,8 @@
Controls,
ControlButton,
SvelteFlowProvider,
type Viewport
type Viewport,
SelectionMode
} from '@xyflow/svelte'
import {
graphBuilder,
@@ -35,10 +36,10 @@
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, Hand } from 'lucide-svelte'
import Toggle from '../Toggle.svelte'
import DataflowEdge from './renderers/edges/DataflowEdge.svelte'
import { encodeState, readFieldsRecursively } from '$lib/utils'
import { encodeState, readFieldsRecursively, getModifierKey, isMac } from '$lib/utils'
import BranchOneStart from './renderers/nodes/BranchOneStart.svelte'
import NoBranchNode from './renderers/nodes/NoBranchNode.svelte'
import HiddenBaseEdge from './renderers/edges/HiddenBaseEdge.svelte'
@@ -57,14 +58,26 @@
import type { FlowGraphAssetContext } from '../flows/types'
import AiToolNode, { computeAIToolNodes } from './renderers/nodes/AIToolNode.svelte'
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 PaneContextMenu from './PaneContextMenu.svelte'
import { SelectionManager } from './selectionUtils.svelte'
import { ChangeTracker } from '$lib/svelte5Utils.svelte'
import { NoteManager } from './noteManager.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'
import { computeNoteNodes } from './noteUtils.svelte'
import { Tooltip } from '../meltComponents'
import { getNoteEditorContext } from './noteEditor.svelte'
let useDataflow: Writable<boolean | undefined> = writable<boolean | undefined>(false)
let showAssets: Writable<boolean | undefined> = writable<boolean | undefined>(true)
let showNotes = $state(true)
const triggerContext = getContext<TriggerContext>('TriggerContext')
@@ -85,7 +98,7 @@
testModuleStates?: ModulesTestStates
moduleActions?: Record<string, AIModuleAction>
inputSchemaModified?: boolean
selectedId?: Writable<string | undefined>
selectionManager?: SelectionManager
path?: string | undefined
newFlow?: boolean
insertable?: boolean
@@ -108,7 +121,10 @@
flowJob?: Job | undefined
showJobStatus?: boolean
suspendStatus?: Record<string, { job: Job; nb: number }>
noteMode?: boolean
notes?: FlowNote[]
chatInputEnabled?: boolean
multiSelectEnabled?: boolean
onDelete?: (id: string) => void
onInsert?: (detail: {
sourceId?: string
@@ -138,6 +154,8 @@
onHideJobStatus?: () => void
onShowModuleDiff?: (moduleId: string) => void
flowHasChanged?: boolean
exitNoteMode?: () => void
onNotePositionUpdate?: (noteId: string, position: { x: number; y: number }) => void
// Viewport synchronization props (for diff viewer)
sharedViewport?: Viewport
onViewportChange?: (viewport: Viewport, isUserInitiated: boolean) => void
@@ -152,7 +170,6 @@
onNewBranch = undefined,
onSelect = undefined,
onChangeId = undefined,
onUpdateMock = undefined,
onSelectedIteration = undefined,
success = undefined,
@@ -166,7 +183,7 @@
testModuleStates = undefined,
moduleActions = undefined,
inputSchemaModified = undefined,
selectedId = writable<string | undefined>(undefined),
selectionManager: selectionManagerProp = undefined,
path = undefined,
newFlow = false,
insertable = false,
@@ -196,17 +213,68 @@
showJobStatus = false,
suspendStatus = {},
flowHasChanged = false,
noteMode = false,
notes = undefined,
exitNoteMode = undefined,
onNotePositionUpdate = undefined,
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 })
// Initialize note manager with fine-grained reactivity
const noteManager = new NoteManager(
() => notes ?? [],
(newNodes) => {
nodes = newNodes
},
() => nodes
)
// Runtime text height tracking for notes (not stored in FlowNote)
let noteTextHeights = $state<Record<string, number>>({})
// Reference to pane context menu component
let paneContextMenu: PaneContextMenu | undefined = $state(undefined)
let flowContainer: HTMLDivElement | undefined = $state(undefined)
// Selection manager - create one if not provided
let selectionManager = selectionManagerProp || new SelectionManager()
const selectedId = $derived(selectionManager.getSelectedId())
const noteEditorContext = getNoteEditorContext()
// Function to calculate extra gap needed for notes below the lowest flow nodes
function calculateNoteGap(notes: FlowNote[] | undefined): number {
console.log('calculateNoteGap', notes)
if (!notes || notes.length === 0) {
return 0
}
let maxNoteBelowGap = 0
notes.forEach((note) => {
if (note.position?.y && note.position.y < 0) {
maxNoteBelowGap = Math.max(maxNoteBelowGap, -note.position.y)
}
})
return maxNoteBelowGap
}
// Calculate note gap based on current nodes and notes
const topPadding = editMode ? 100 : 24
const yOffset = calculateNoteGap(notes) + topPadding
setGraphContext({
selectionManager: selectionManager,
useDataflow,
showAssets,
noteManager,
clearFlowSelection,
yOffset
} as any)
if (triggerContext && allowSimplifiedPoll) {
if (isSimplifiable(modules)) {
@@ -235,9 +303,15 @@
)
}
type NodeDep = { id: string; parentIds?: string[]; offset?: number }
type NodeDep = {
id: string
parentIds?: string[]
offset?: number
data?: { assets?: AssetWithAltAccessType[] }
}
type NodePos = { position: { x: number; y: number } }
let lastNodes: [NodeDep[], (NodeDep & NodePos)[]] | undefined = undefined
function layoutNodes(nodes: NodeDep[]): (NodeDep & NodePos)[] {
let lastResult = lastNodes?.[1]
if (lastResult && deepEqual(nodes, lastNodes?.[0])) {
@@ -292,7 +366,6 @@
boxSize = layout(dag as any)
}
const yOffset = insertable ? 100 : 0
const newNodes = dag.descendants().map((des) => ({
id: des.data.id,
position: {
@@ -306,7 +379,7 @@
NODE.width / 2 -
(width - fullWidth) / 2
: 0,
y: (des.y || 0) + yOffset
y: des.y || 0
}
}))
@@ -316,7 +389,7 @@
let eventHandler = {
deleteBranch: (detail, label) => {
$selectedId = label
selectionManager.selectId(label)
onDeleteBranch?.(detail)
},
insert: (detail) => {
@@ -324,9 +397,6 @@
},
select: (modId) => {
if (!notSelectable) {
if ($selectedId != modId) {
$selectedId = modId
}
onSelect?.(modId)
}
},
@@ -387,6 +457,25 @@
let height = $state(0)
// Derived nodes with yOffset applied to all nodes uniformly and selectable flag set to false if notSelectable is true
const nodesWithOffset = $derived.by(() => {
return nodes.map((node) => {
if (node.type && !AI_OR_ASSET_NODE_TYPES.includes(node.type)) {
return {
...node,
position: { ...node.position, y: node.position.y + yOffset },
selectable: notSelectable ? false : node.selectable
}
}
return {
...node,
selectable: notSelectable ? false : node.selectable
}
})
})
// Note feature state
function isSimplifiable(modules: FlowModule[] | undefined): boolean {
if (!modules || modules?.length !== 2) {
return false
@@ -399,17 +488,40 @@
return false
}
// Clear SvelteFlow's internal selection by creating new nodes array
function clearFlowSelection() {
nodes = nodes.map((node) => {
if (node.selected) {
return { ...node, selected: false }
}
return node
})
}
// Keyboard event handling
function handleKeyDown(event: KeyboardEvent) {
selectionManager.handleKeyDown(event)
noteManager.handleKeyDown(event)
if (event.key === 'Escape') {
if (noteMode) {
exitNoteMode?.()
}
}
}
async function updateStores() {
if (graph.error) {
return
}
// console.log('compute')
let layoutedNodes = layoutNodes(
Object.values(graph.nodes).map((n) => ({
id: n.id,
parentIds: n.parentIds,
offset: n.data.offset ?? 0
offset: n.data.offset ?? 0,
data: { assets: (n.data as any).assets }
}))
)
let newNodes: (Node & NodeLayout)[] = layoutedNodes.map((n) => ({ ...n, ...graph.nodes[n.id] }))
@@ -430,11 +542,50 @@
}))
}
let aiToolNodesResult = computeAIToolNodes(newNodes, eventHandler, insertable, flowModuleStates)
nodes = [
...newNodes.map((n) => ({ ...n, position: aiToolNodesResult.newNodePositions[n.id] })),
let nodesAfterAITools = newNodes.map((n) => ({
...n,
position: aiToolNodesResult.newNodePositions[n.id]
}))
let finalNodes = [
...nodesAfterAITools,
...(assetNodesResult?.newAssetNodes ?? []),
...aiToolNodesResult.toolNodes
]
// Compute note nodes and positions
let noteNodesResult = showNotes
? computeNoteNodes(
finalNodes.map((n) => ({
id: n.id,
position: n.position,
parentIds: n.parentIds,
offset: n.data?.offset ?? 0,
data: { assets: (n.data as any)?.assets },
type: n.type
})),
notes ?? [],
noteTextHeights,
(noteId: string, height: number) => {
noteTextHeights[noteId] = height
noteManager.render()
},
editMode,
noteEditorContext
)
: undefined
// Apply note positioning to nodes if notes are enabled
if (noteNodesResult) {
finalNodes = finalNodes.map((n) => ({
...n,
position: noteNodesResult.newNodePositions[n.id] || n.position
}))
}
// update nodes
nodes = [...finalNodes, ...(noteNodesResult?.noteNodes ?? [])]
edges = [
...(assetNodesResult?.newAssetEdges ?? []),
...aiToolNodesResult.toolEdges,
@@ -442,7 +593,13 @@
]
await tick()
height = Math.max(...nodes.map((n) => n.position.y + NODE.height + 100), minHeight)
if (nodes.length === 0) {
height = minHeight
} else {
const minY = Math.min(...nodes.map((n) => n.position.y))
const maxBottom = Math.max(...nodes.map((n) => n.position.y + NODE.height + 100))
height = Math.max(maxBottom - minY, minHeight)
}
}
const nodeTypes = {
@@ -463,7 +620,8 @@
asset: AssetNode,
assetsOverflowed: AssetsOverflowedNode,
aiTool: AiToolNode,
newAiTool: NewAiToolNode
newAiTool: NewAiToolNode,
note: NoteNode
} as any
const edgeTypes = {
@@ -501,7 +659,7 @@
testModuleStates: untrack(() => testModuleStates),
moduleActions: untrack(() => moduleActions),
inputSchemaModified: untrack(() => inputSchemaModified),
selectedId: untrack(() => $selectedId),
selectedId: untrack(() => selectedId),
path,
newFlow,
cache,
@@ -523,7 +681,7 @@
eventHandler,
success,
$useDataflow,
untrack(() => $selectedId),
untrack(() => selectedId),
moving,
simplifiableFlow,
triggerNode ? path : undefined,
@@ -533,20 +691,80 @@
let hideAssetsToggle = $derived(
$showAssets && Object.values(nodes).every((n) => n.type !== 'asset')
)
let hideNotesToggle = $derived(!notes || notes.length === 0)
$effect(() => {
;[graph, allowSimplifiedPoll, $showAssets]
untrack(() => updateStores())
;[graph, allowSimplifiedPoll, $showAssets, showNotes, noteManager.renderCount]
untrack(async () => {
await updateStores()
})
})
// Add global keyboard event listener for selection controls
onMount(() => {
function globalKeyDownHandler(event: KeyboardEvent) {
handleKeyDown(event)
}
document.addEventListener('keydown', globalKeyDownHandler)
return () => {
document.removeEventListener('keydown', globalKeyDownHandler)
}
})
// DOM event handling for pane clicks in rect-select mode
$effect(() => {
// Only add manual handling when in rect-select mode
if (selectionManager.mode !== 'rect-select') {
return
}
function paneClickHandler(event: Event) {
// Find the pane within our specific flow container
const pane = flowContainer?.querySelector('.svelte-flow__pane')
if (!pane || !event.target || !pane.contains(event.target as Element)) {
return
}
// Don't trigger if clicking on nodes or UI elements
const target = event.target as Element
if (
target.closest('.svelte-flow__node') ||
target.closest('button') ||
target.closest('[role="button"]') ||
target.closest('.svelte-flow__controls')
) {
return
}
// Trigger the same logic as onpaneclick
document.dispatchEvent(new Event('focus'))
selectionManager.clearSelection()
}
const pane = flowContainer?.querySelector('.svelte-flow__pane')
if (pane) {
pane.addEventListener('click', paneClickHandler)
}
return () => {
const pane = flowContainer?.querySelector('.svelte-flow__pane')
if (pane) {
pane.removeEventListener('click', paneClickHandler)
}
}
})
let showDataflow = $derived(
$selectedId != undefined &&
!$selectedId.startsWith('constants') &&
!$selectedId.startsWith('settings') &&
$selectedId !== 'failure' &&
$selectedId !== 'preprocessor' &&
$selectedId !== 'Result' &&
$selectedId !== 'triggers'
selectedId !== undefined &&
selectedId !== null &&
!selectedId?.startsWith('constants') &&
!selectedId?.startsWith('settings') &&
selectedId !== 'failure' &&
selectedId !== 'preprocessor' &&
selectedId !== 'Result' &&
selectedId !== 'Trigger'
)
let debouncedWidth: number | undefined = $state(undefined)
let timeout: number | undefined = $state(undefined)
@@ -582,6 +800,14 @@
export function zoomOut() {
viewportSynchronizer?.zoomOut()
}
export function enableNotes() {
if (!showNotes) {
showNotes = true
}
}
const modifierKey = isMac() ? 'Meta' : 'Control'
</script>
{#if insertable}
@@ -589,8 +815,9 @@
{/if}
<div
style={`height: ${height}px; max-height: ${maxHeight}px;`}
class="overflow-clip"
class="overflow-clip relative"
bind:clientWidth={debouncedWidth}
bind:this={flowContainer}
>
{#if graph?.error}
<div class="center-center p-2">
@@ -615,14 +842,29 @@
bind:this={viewportSynchronizer}
/>
{/if}
<PaneContextMenu {editMode} bind:this={paneContextMenu} />
<SvelteFlow
onpaneclick={(e) => {
onpaneclick={() => {
document.dispatchEvent(new Event('focus'))
selectionManager.clearSelection()
}}
onpanecontextmenu={({ event }) => {
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
}
onNotePositionUpdate?.(node.id, positionWithOffset)
}
}}
onmove={(event, viewport) => {
viewportSynchronizer?.handleLocalViewportChange(event, viewport)
}}
{nodes}
nodes={nodesWithOffset}
{edges}
{edgeTypes}
{nodeTypes}
@@ -633,26 +875,85 @@
connectionLineType={ConnectionLineType.SmoothStep}
defaultEdgeOptions={{ type: 'smoothstep' }}
preventScrolling={scroll}
selectionOnDrag={selectionManager.mode === 'rect-select'}
elementsSelectable={true}
selectionMode={SelectionMode.Partial}
selectionKey={selectionManager.mode === 'rect-select' || !editMode ? null : modifierKey}
panActivationKey={selectionManager.mode === 'rect-select' ? modifierKey : null}
panOnDrag={selectionManager.mode === 'rect-select' ? [1] : true}
zoomOnDoubleClick={false}
elementsSelectable={false}
elevateNodesOnSelect={false}
{proOptions}
multiSelectionKey={'Shift'}
nodesDraggable={false}
--background-color={false}
>
<div class="absolute inset-0 !bg-surface-secondary h-full" id="flow-graph-v2"></div>
{#if noteMode}
<NoteTool {exitNoteMode} {yOffset} />
{/if}
{#if multiSelectEnabled}
<SelectionBoundingBox
selectedNodes={selectionManager.selectedIds}
allNodes={nodesWithOffset as (Node & { type: string })[]}
/>
{/if}
<!-- SelectionTool for handling selection changes and filtering -->
<SelectionTool {selectionManager} clearGraphSelection={clearFlowSelection} />
{#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">
<Tooltip>
<ControlButton
onclick={() => {
selectionManager.mode =
selectionManager.mode === 'normal' ? 'rect-select' : 'normal'
}}
>
{#if selectionManager.mode === 'rect-select'}
<MousePointer size="14" />
{:else}
<Hand size="14" />
{/if}
</ControlButton>
{#snippet text()}
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<Hand size="14" />
<span class="text-secondary"
><strong class="text-primary">Grab</strong>: Click and drag to pan. Hold
<kbd class="text-primary text-lg">{getModifierKey()}</kbd> to box select.</span
>
</div>
<div class="flex items-center gap-2">
<MousePointer size="14" />
<span class="text-secondary"
><strong class="text-primary">Select</strong> Click and drag to box
select. Hold
<kbd class="text-primary text-lg">{getModifierKey()}</kbd> to pan.</span
>
</div>
</div>
{/snippet}
</Tooltip>
</div>
{/if}
{#if download}
<ControlButton
onclick={() => {
try {
localStorage.setItem(
'svelvet',
encodeState({ modules, failureModule, preprocessorModule })
encodeState({ modules, failureModule, preprocessorModule, notes })
)
} catch (e) {
console.error('error interacting with local storage', e)
@@ -678,6 +979,9 @@
{#if !hideAssetsToggle}
<Toggle bind:checked={$showAssets} size="xs" options={{ right: 'Assets' }} />
{/if}
{#if !hideNotesToggle}
<Toggle bind:checked={showNotes} size="xs" options={{ right: 'Notes' }} />
{/if}
{#if showDataflow}
<Toggle bind:checked={$useDataflow} size="xs" options={{ right: 'Dataflow' }} />
{/if}
@@ -703,4 +1007,9 @@
:global(.svelte-flow__edgelabel-renderer) {
@apply z-50;
}
:global(.svelte-flow__selection) {
display: none;
pointer-events: none;
}
</style>
@@ -0,0 +1,47 @@
<script lang="ts">
import ContextMenu, { type ContextMenuItem } from '../common/contextmenu/ContextMenu.svelte'
import { StickyNote } from 'lucide-svelte'
import type { Snippet } from 'svelte'
import { getNoteEditorContext } from './noteEditor.svelte'
import { getGraphContext } from './graphContext'
import { tick } from 'svelte'
interface Props {
children: Snippet
selectedNodeIds: string[]
}
let { children, selectedNodeIds }: Props = $props()
// Get NoteEditor context for group note creation
const noteEditorContext = getNoteEditorContext()
// Get Graph context for clearFlowSelection function
const graphContext = getGraphContext()
const menuItems: ContextMenuItem[] = $derived([
{
id: 'create-group-note',
label: `Create group note (${selectedNodeIds.length} nodes)`,
icon: StickyNote,
disabled: selectedNodeIds.length === 0 || !noteEditorContext?.noteEditor,
onClick: () => {
if (selectedNodeIds.length > 0 && noteEditorContext?.noteEditor && graphContext) {
// Create the group note first
noteEditorContext.noteEditor.createGroupNote(selectedNodeIds)
// Wait for next tick to ensure DOM updates
tick().then(() => {
graphContext?.clearFlowSelection?.()
graphContext?.selectionManager.selectId(selectedNodeIds[0])
})
}
}
}
])
</script>
{#if noteEditorContext?.noteEditor && selectedNodeIds.length > 1}
<ContextMenu items={menuItems}>
{@render children()}
</ContextMenu>
{/if}
@@ -0,0 +1,50 @@
<script lang="ts">
import { Palette } from 'lucide-svelte'
import Popover from '../meltComponents/Popover.svelte'
import { NoteColor, NOTE_COLOR_SWATCHES } from './noteColors'
import Button from '../common/button/Button.svelte'
interface Props {
selectedColor: NoteColor
onColorChange: (color: NoteColor) => void
isOpen?: boolean
}
let { selectedColor, onColorChange, isOpen = $bindable(false) }: Props = $props()
</script>
<Popover
placement="bottom"
contentClasses="p-2"
floatingConfig={{ strategy: 'absolute' }}
usePointerDownOutside
bind:isOpen
>
{#snippet trigger()}
<Button
variant="subtle"
unifiedSize="xs"
selected={isOpen}
nonCaptureEvent
title={'Select color'}
startIcon={{ icon: Palette }}
iconOnly
/>
{/snippet}
{#snippet content()}
<div class="grid grid-cols-5 gap-1" style="min-width: 140px">
{#each Object.values(NoteColor) as color (color)}
<button
class="w-6 h-6 rounded-full hover:scale-110 transition-transform duration-100 {NOTE_COLOR_SWATCHES[
color
]} {selectedColor === color ? 'ring-2 ring-accent' : ' dark:border-gray-600'}"
onclick={() => {
onColorChange(color)
}}
title={color.charAt(0).toUpperCase() + color.slice(1)}
aria-label={`Select ${color} color`}
></button>
{/each}
</div>
{/snippet}
</Popover>
@@ -0,0 +1,216 @@
<script lang="ts">
import { useSvelteFlow, type XYPosition } from '@xyflow/svelte'
import { getNoteEditorContext } from './noteEditor.svelte'
import { DEFAULT_NOTE_COLOR, MIN_NOTE_WIDTH, MIN_NOTE_HEIGHT } from './noteColors'
import { StickyNote } from 'lucide-svelte'
import ContextMenu, { type ContextMenuItem } from '../common/contextmenu/ContextMenu.svelte'
interface Props {
exitNoteMode?: () => void
yOffset: number
}
let { exitNoteMode, yOffset }: Props = $props()
// Get NoteEditor context for direct note creation
const noteEditorContext = getNoteEditorContext()
const { screenToFlowPosition, getViewport } = useSvelteFlow()
let isDrawing = $state(false)
let startPosition: XYPosition | null = $state(null)
let endPosition: XYPosition | null = $state(null)
let rect: DOMRect | null = $state(null)
let contextMenuPosition: XYPosition | null = $state(null)
function onPointerDown(event: PointerEvent) {
// 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
}
isDrawing = true
}
function onPointerMove(event: PointerEvent) {
if (event.buttons !== 1) return
// Use page coordinates as reference
const target = event.currentTarget as Element
rect = target.getBoundingClientRect()
endPosition = {
x: event.pageX - rect.left,
y: event.pageY - rect.top
}
}
function onPointerUp() {
if (!isDrawing || !startPosition || !endPosition || !rect) return
// We need to convert the start and end positions to absolute positions to then convert to flow 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
}
const flowPosition = screenToFlowPosition({
x: Math.min(absoluteStartPosition.x, absoluteEndPosition.x),
y: Math.min(absoluteStartPosition.y, absoluteEndPosition.y)
})
const position = {
x: flowPosition.x,
y: flowPosition.y - (yOffset || 0)
}
const zoom = getViewport().zoom
const size = {
width: Math.max(
MIN_NOTE_WIDTH,
Math.abs(absoluteEndPosition.x - absoluteStartPosition.x) / zoom
),
height: Math.max(
MIN_NOTE_HEIGHT,
Math.abs(absoluteEndPosition.y - absoluteStartPosition.y) / zoom
)
}
// Create the actual note using NoteEditor context
if (noteEditorContext?.noteEditor) {
noteEditorContext.noteEditor.addNote({
text: '### Free note\nDouble click to edit me',
position,
size,
color: DEFAULT_NOTE_COLOR,
type: 'free',
locked: false
})
}
// Exit note mode after creating note
exitNoteMode?.()
// Reset state
isDrawing = false
startPosition = null
}
function handleAddStickyNote() {
if (!noteEditorContext?.noteEditor || !contextMenuPosition) return
noteEditorContext.noteEditor.addNote({
text: '### Free note\nDouble click to edit me',
position: contextMenuPosition,
size: { width: 300, height: 200 },
color: DEFAULT_NOTE_COLOR,
type: 'free',
locked: false
})
// Clear the position after use
contextMenuPosition = null
exitNoteMode?.()
}
function handleItemClick(item: ContextMenuItem) {
if (item.id === 'add-sticky-note') {
handleAddStickyNote()
}
}
const contextMenuItems: ContextMenuItem[] = [
{
id: 'add-sticky-note',
label: 'Add sticky note',
icon: StickyNote,
onClick: handleAddStickyNote
}
]
const previewNote = $derived(
startPosition && endPosition
? {
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'])
}
}
: null
)
</script>
<ContextMenu items={contextMenuItems} onItemClick={handleItemClick}>
<div
class="tool-overlay"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
oncontextmenu={(e) => {
// Capture the position when context menu is triggered
const flowPosition = screenToFlowPosition({
x: e.clientX,
y: e.clientY
})
contextMenuPosition = {
x: flowPosition.x,
y: flowPosition.y - yOffset
}
}}
role="button"
tabindex="0"
aria-label="Click and drag to create a note, or right-click to add a sticky note"
onkeydown={(e) => {
if (e.key === 'Escape') {
if (isDrawing) {
// Cancel current drawing
isDrawing = false
startPosition = null
} else {
// Exit note mode
exitNoteMode?.()
}
}
}}
>
<!-- Preview note while drawing -->
{#if previewNote}
<div
class="absolute border-2 border-dashed border-lime-400 bg-lime-100 bg-opacity-50 rounded-md 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>
</ContextMenu>
<style>
.tool-overlay {
pointer-events: auto;
position: absolute;
top: 0;
left: 0;
z-index: 4;
height: 100%;
width: 100%;
transform-origin: top left;
cursor: crosshair;
touch-action: none;
}
</style>
@@ -0,0 +1,115 @@
<script lang="ts">
import { useSvelteFlow } from '@xyflow/svelte'
import { StickyNote } from 'lucide-svelte'
import { getNoteEditorContext } from './noteEditor.svelte'
import { DEFAULT_NOTE_COLOR } from './noteColors'
import { fly } from 'svelte/transition'
import {
getContextMenuContainerClass,
CONTEXT_MENU_ITEM_BASE_CLASS,
CONTEXT_MENU_ITEM_HOVER_CLASS
} from '../common/contextmenu/contextMenuStyles'
import { getGraphContext } from './graphContext'
interface Props {
editMode?: boolean
}
let { editMode = false }: Props = $props()
const { screenToFlowPosition } = useSvelteFlow()
const noteEditorContext = getNoteEditorContext()
const graphContext = getGraphContext()
let contextMenuVisible = $state(false)
let contextMenuPosition = $state<{ x: number; y: number }>({ x: 0, y: 0 })
let pendingFlowPosition = $state<{ x: number; y: number } | null>(null)
function handlePaneContextMenu(event: MouseEvent) {
// Only show context menu in edit mode
if (!editMode || !noteEditorContext?.noteEditor) {
return
}
event.preventDefault()
event.stopPropagation()
// Store screen coordinates for context menu positioning
contextMenuPosition = {
x: event.clientX,
y: event.clientY
}
// Convert to flow coordinates for note placement
pendingFlowPosition = screenToFlowPosition({
x: event.clientX,
y: event.clientY
})
contextMenuVisible = true
}
function handleAddStickyNote() {
if (noteEditorContext?.noteEditor && pendingFlowPosition) {
noteEditorContext.noteEditor.addNote({
text: '### Free note\nDouble click to edit me',
position: {
x: pendingFlowPosition.x,
y: pendingFlowPosition.y - (graphContext?.yOffset || 0)
},
size: { width: 300, height: 200 },
color: DEFAULT_NOTE_COLOR,
type: 'free',
locked: false
})
}
contextMenuVisible = false
}
// Export the handler to be used by parent
export function onPaneContextMenu(event: MouseEvent) {
handlePaneContextMenu(event)
}
</script>
{#if contextMenuVisible}
<!-- Context menu -->
<div
class="fixed {getContextMenuContainerClass('z-[9999]')}"
style="left: {contextMenuPosition.x}px; top: {contextMenuPosition.y}px;"
transition:fly={{ duration: 150, y: -10 }}
role="menu"
tabindex="-1"
onclick={(e) => {
e.stopPropagation()
}}
onkeydown={(e) => {
if (e.key === 'Escape') {
contextMenuVisible = false
}
}}
>
<button
class="{CONTEXT_MENU_ITEM_BASE_CLASS} {CONTEXT_MENU_ITEM_HOVER_CLASS}"
onclick={handleAddStickyNote}
type="button"
>
<StickyNote size={14} class="mr-2" />
<span>Add sticky note</span>
</button>
</div>
<!-- Invisible click catcher to close context menu -->
<div
class="fixed inset-0 z-[9998]"
role="presentation"
onclick={() => {
contextMenuVisible = false
}}
oncontextmenu={(e) => {
e.preventDefault()
contextMenuVisible = false
}}
></div>
{/if}
@@ -0,0 +1,82 @@
<script lang="ts">
import { ViewportPortal, type Node } from '@xyflow/svelte'
import { calculateNodesBoundsWithOffset } from './util'
import { StickyNote } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import { getNoteEditorContext } from './noteEditor.svelte'
import { getGraphContext } from './graphContext'
import { tick } from 'svelte'
interface Props {
selectedNodes: string[]
allNodes: (Node & { type: string })[]
}
let { selectedNodes, allNodes }: Props = $props()
// Get NoteEditor context for group note creation
const noteEditorContext = getNoteEditorContext()
// Get Graph context for clearFlowSelection function
const graphContext = getGraphContext()
function handleAddGroupNote() {
if (selectedNodes.length > 0 && noteEditorContext?.noteEditor && graphContext) {
// Create the group note first
noteEditorContext.noteEditor.createGroupNote(selectedNodes)
// Wait for next tick to ensure DOM updates
tick().then(() => {
graphContext?.clearFlowSelection?.()
graphContext?.selectionManager.clearSelection()
})
}
}
let bounds = $derived(() => {
if (selectedNodes.length === 0) {
return null
}
// Calculate flow coordinates bounds, accounting for CSS offset and expanded subflows
const { minX, minY, maxX, maxY } = calculateNodesBoundsWithOffset(selectedNodes, allNodes)
// Add padding in flow coordinates
const padding = 4
// Return flow coordinates directly - ViewportPortal handles transformation
return {
x: minX - padding,
y: minY - padding,
width: maxX - minX + 2 * padding,
height: maxY - minY + 2 * padding
}
})
</script>
{#if bounds() && selectedNodes.length > 1}
{@const currentBounds = bounds()!}
<ViewportPortal target="front">
<div
class={'absolute cursor-pointer bg-surface-selected/40 rounded-md pointer-events-none'}
style:transform="translate({currentBounds.x}px, {currentBounds.y}px)"
style:width="{currentBounds.width}px"
style:height="{currentBounds.height}px"
style:z-index="10"
>
<!-- Add Group Note Button positioned in top-right corner -->
{#if noteEditorContext?.noteEditor}
<div class="absolute -top-4 -right-1 z-20" style="pointer-events: auto;">
<Button
unifiedSize="sm"
variant="accent"
title="Create group note ({selectedNodes.length} nodes)"
onclick={handleAddGroupNote}
startIcon={{ icon: StickyNote }}
>
Create group note ({selectedNodes.length} nodes)
</Button>
</div>
{/if}
</div>
</ViewportPortal>
{/if}
@@ -0,0 +1,43 @@
<script lang="ts">
import { useOnSelectionChange, useStore, type Node } from '@xyflow/svelte'
import type { SelectionManager } from './selectionUtils.svelte'
interface Props {
selectionManager: SelectionManager
clearGraphSelection: () => void
}
let { selectionManager, clearGraphSelection }: Props = $props()
selectionManager.setClearGraphSelection(clearGraphSelection)
// Get store to access selectionRect
const store = useStore()
// 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 > 0) {
selectionManager.selectNodes(selectedNodes)
}
})
</script>
<!-- Render custom selection box during drag selection -->
{#if store.selectionRect}
{@const bounds = store.selectionRect!}
<div
class="absolute rounded cursor-pointer bg-surface-selected/30 pointer-events-none"
style="
left: {bounds.x}px;
top: {bounds.y}px;
width: {bounds.width}px;
height: {bounds.height}px;
z-index: 10;
"
>
</div>
{/if}
@@ -88,6 +88,7 @@ export type NodeLayout = {
data: {
offset?: number
}
selectable?: boolean
} & FlowNode
export type FlowNode =
@@ -447,7 +448,8 @@ export function graphBuilder(
moduleAction: extra.moduleActions?.[module.id],
onShowModuleDiff: extra.onShowModuleDiff
},
type: 'module'
type: 'module',
selectable: true
})
return module.id
@@ -540,7 +542,8 @@ export function graphBuilder(
...extra,
insertable: extra.insertable && !options?.disableInsert && prefix == undefined,
shouldOffsetInsertBtnDueToAssetNode: nodeIdsWithOutputAssets.has(sourceId)
}
},
selectable: false
})
}
@@ -596,7 +599,7 @@ export function graphBuilder(
}
const resultNode: NodeLayout = {
id: 'result',
id: 'Result',
data: {
eventHandlers: eventHandlers,
success: success,
@@ -1085,14 +1088,14 @@ export function graphBuilder(
let pid = x[0]
if (input?.startsWith('flow_input.iter')) {
const parent = dfsByModule(selectedId!, modules ?? [])?.pop()
const parent = dfsByModule(selectedId, modules ?? [])?.pop()
if (parent?.id) {
pid = parent.id
}
}
addEdge(pid, selectedId!, undefined, undefined, {
addEdge(pid, selectedId, undefined, undefined, {
customId: `dep-${pid}-${selectedId}-${input}-${index}`,
type: 'dataflowedge'
})
@@ -1102,7 +1105,7 @@ export function graphBuilder(
Object.entries(deps.dependents).forEach((x, i) => {
let pid = x[0]
addEdge(selectedId!, pid, undefined, undefined, {
addEdge(selectedId, pid, undefined, undefined, {
customId: `dep-${selectedId}-${pid}-${i}`,
type: 'dataflowedge'
})
@@ -0,0 +1,19 @@
import { getContext, setContext } from 'svelte'
import type { SelectionManager } from './selectionUtils.svelte'
import type { NoteManager } from './noteManager.svelte'
import type { Writable } from 'svelte/store'
export type GraphContext = {
selectionManager: SelectionManager
useDataflow: Writable<boolean | undefined>
showAssets: Writable<boolean | undefined>
noteManager?: NoteManager
clearFlowSelection?: () => void
yOffset?: number
}
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)
@@ -0,0 +1,86 @@
type FlowNode = { id: string; parentIds?: string[] }
/**
* Use a simple algorithm to complete a group and split it into connected components
*/
export function completeAndSplitGroup(groupNodes: string[], flowNodes: FlowNode[]): string[][] {
if (groupNodes.length <= 1) {
return groupNodes.length === 1 ? [groupNodes] : []
}
// Build parent map for upward traversal only
const parents = new Map<string, string[]>()
for (const node of flowNodes) {
parents.set(node.id, node.parentIds || [])
}
const groupSet = new Set(groupNodes)
const assignedComponent = new Map<string, number>()
const components: Array<Set<string>> = []
const mergeComponents = (fromIdx: number, toIdx: number): void => {
if (fromIdx === toIdx) return
const target = components[toIdx]
const source = components[fromIdx]
source.forEach((node) => target.add(node))
source.clear()
for (const [nodeId, idx] of assignedComponent.entries()) {
if (idx === fromIdx) {
assignedComponent.set(nodeId, toIdx)
}
}
}
for (const startNode of groupNodes) {
if (assignedComponent.has(startNode)) continue
const componentIdx = components.length
components.push(new Set([startNode]))
assignedComponent.set(startNode, componentIdx)
const stack: { nodeId: string; path: string[]; seen: Set<string> }[] = [
{ nodeId: startNode, path: [startNode], seen: new Set([startNode]) }
]
while (stack.length > 0) {
const { nodeId, path, seen } = stack.pop()!
const parentIds = parents.get(nodeId) || []
for (const parentId of parentIds) {
if (seen.has(parentId)) continue
const newPath = [...path, parentId]
const newSeen = new Set(seen)
newSeen.add(parentId)
if (groupSet.has(parentId)) {
const existingIdx = assignedComponent.get(parentId)
if (existingIdx === undefined) {
assignedComponent.set(parentId, componentIdx)
components[componentIdx].add(parentId)
stack.push({ nodeId: parentId, path: [parentId], seen: new Set([parentId]) })
} else if (existingIdx !== componentIdx) {
mergeComponents(existingIdx, componentIdx)
}
for (const node of newPath) {
components[componentIdx].add(node)
}
} else {
stack.push({ nodeId: parentId, path: newPath, seen: newSeen })
}
}
}
}
return components
.filter((component) => component.size > 0)
.map((component) =>
Array.from(component)
.filter((nodeId) => !nodeId.startsWith('subflow:'))
.sort()
)
.filter((component) => component.length > 0)
}
@@ -0,0 +1,135 @@
// Note color definitions with Tailwind classes for light and dark mode
export enum NoteColor {
YELLOW = 'yellow',
BLUE = 'blue',
GREEN = 'green',
PURPLE = 'purple',
PINK = 'pink',
ORANGE = 'orange',
RED = 'red',
CYAN = 'cyan',
LIME = 'lime',
GRAY = 'gray'
}
export interface NoteColorConfig {
background: string
outline: string
outlineHover: string
text: string
hover: string
}
// Color configurations for each note color with dark mode support
export const NOTE_COLORS: Record<NoteColor, NoteColorConfig> = {
[NoteColor.YELLOW]: {
background: 'bg-yellow-200 dark:bg-yellow-900',
outline: 'outline-yellow-300 dark:outline-yellow-600',
outlineHover: 'outline-yellow-300/60 dark:outline-yellow-600/60',
text: 'text-yellow-900 dark:text-yellow-100',
hover: 'hover:bg-yellow-200 dark:hover:bg-yellow-800'
},
[NoteColor.BLUE]: {
background: 'bg-blue-100 dark:bg-blue-950',
outline: 'outline-blue-300 dark:outline-blue-600',
outlineHover: 'outline-blue-300/60 dark:outline-blue-600/60',
text: 'text-blue-900 dark:text-blue-100',
hover: 'hover:bg-blue-200 dark:hover:bg-blue-800'
},
[NoteColor.GREEN]: {
background: 'bg-green-200 dark:bg-green-900',
outline: 'outline-green-300 dark:outline-green-600',
outlineHover: 'outline-green-300/60 dark:outline-green-600/60',
text: 'text-green-900 dark:text-green-100',
hover: 'hover:bg-green-200 dark:hover:bg-green-800'
},
[NoteColor.PURPLE]: {
background: 'bg-purple-200 dark:bg-purple-900',
outline: 'outline-purple-300 dark:outline-purple-600',
outlineHover: 'outline-purple-300/60 dark:outline-purple-600/60',
text: 'text-purple-900 dark:text-purple-100',
hover: 'hover:bg-purple-200 dark:hover:bg-purple-800'
},
[NoteColor.PINK]: {
background: 'bg-pink-200 dark:bg-pink-900',
outline: 'outline-pink-300 dark:outline-pink-600',
outlineHover: 'outline-pink-300/60 dark:outline-pink-600/60',
text: 'text-pink-900 dark:text-pink-100',
hover: 'hover:bg-pink-200 dark:hover:bg-pink-800'
},
[NoteColor.ORANGE]: {
background: 'bg-orange-200 dark:bg-orange-900',
outline: 'outline-orange-300 dark:outline-orange-600',
outlineHover: 'outline-orange-300/60 dark:outline-orange-600/60',
text: 'text-orange-900 dark:text-orange-100',
hover: 'hover:bg-orange-200 dark:hover:bg-orange-800'
},
[NoteColor.RED]: {
background: 'bg-red-200 dark:bg-red-900',
outline: 'outline-red-300 dark:outline-red-600',
outlineHover: 'outline-red-300/60 dark:outline-red-600/60',
text: 'text-red-900 dark:text-red-100',
hover: 'hover:bg-red-200 dark:hover:bg-red-800'
},
[NoteColor.CYAN]: {
background: 'bg-cyan-200 dark:bg-cyan-900',
outline: 'outline-cyan-300 dark:outline-cyan-600',
outlineHover: 'outline-cyan-300/60 dark:outline-cyan-600/60',
text: 'text-cyan-900 dark:text-cyan-100',
hover: 'hover:bg-cyan-200 dark:hover:bg-cyan-800'
},
[NoteColor.LIME]: {
background: 'bg-lime-200 dark:bg-lime-900',
outline: 'outline-lime-300 dark:outline-lime-600',
outlineHover: 'outline-lime-300/60 dark:outline-lime-600/60',
text: 'text-lime-900 dark:text-lime-100',
hover: 'hover:bg-lime-200 dark:hover:bg-lime-800'
},
[NoteColor.GRAY]: {
background: 'bg-gray-200 dark:bg-gray-800',
outline: 'outline-gray-300 dark:outline-gray-600',
outlineHover: 'outline-gray-300/60 dark:outline-gray-600/60',
text: 'text-gray-900 dark:text-gray-100',
hover: 'hover:bg-gray-200 dark:hover:bg-gray-700'
}
}
// Color swatch colors for the picker (solid colors for the palette dots)
export const NOTE_COLOR_SWATCHES: Record<NoteColor, string> = {
[NoteColor.YELLOW]: 'bg-yellow-400',
[NoteColor.BLUE]: 'bg-blue-400',
[NoteColor.GREEN]: 'bg-green-400',
[NoteColor.PURPLE]: 'bg-purple-400',
[NoteColor.PINK]: 'bg-pink-400',
[NoteColor.ORANGE]: 'bg-orange-400',
[NoteColor.RED]: 'bg-red-400',
[NoteColor.CYAN]: 'bg-cyan-400',
[NoteColor.LIME]: 'bg-lime-400',
[NoteColor.GRAY]: 'bg-gray-400'
}
// Default note color
export const DEFAULT_NOTE_COLOR = NoteColor.GREEN
export const DEFAULT_GROUP_NOTE_COLOR = NoteColor.BLUE
/**
* Get the next available color that's not in the used colors set
* Cycles through all available colors in order
*/
export function getNextAvailableColor(usedColors: Set<NoteColor>): NoteColor {
const allColors = Object.values(NoteColor)
// Find first unused color
for (const color of allColors) {
if (!usedColors.has(color)) {
return color
}
}
// If all colors are used, return the default
return DEFAULT_GROUP_NOTE_COLOR
}
// Minimum note size constraints
export const MIN_NOTE_WIDTH = 275
export const MIN_NOTE_HEIGHT = 60
@@ -0,0 +1,322 @@
import type { FlowNote } from '$lib/gen'
import type { StateStore } from '$lib/utils'
import type { ExtendedOpenFlow } from '../flows/types'
import type { NoteColor } from './noteColors'
import { DEFAULT_GROUP_NOTE_COLOR, getNextAvailableColor } from './noteColors'
import { generateId } from './util'
import { getContext, setContext } from 'svelte'
import { completeAndSplitGroup } from './groupDetectionUtils'
/**
* Utility class for editing flow notes via direct flowStore mutations
* This class is designed to be used in editor contexts via Svelte context
*/
export class NoteEditor {
private flowStore: StateStore<ExtendedOpenFlow>
private onNoteAdded?: () => void
constructor(flowStore: StateStore<ExtendedOpenFlow>, onNoteAdded?: () => void) {
this.flowStore = flowStore
this.onNoteAdded = onNoteAdded
}
/**
* Get the current notes array from the flow store
*/
private getNotes(): FlowNote[] {
return this.flowStore.val.value?.notes || []
}
/**
* Set the notes array in the flow store
*/
private setNotes(notes: FlowNote[]): void {
if (this.flowStore.val.value) {
this.flowStore.val.value.notes = notes
}
}
/**
* Add a new note to the flow
*/
addNote(note: Omit<FlowNote, 'id'>): string {
const notes = this.getNotes()
const newNote: FlowNote = {
id: generateId(),
...note
}
this.setNotes([...notes, newNote])
// Call callback to enable notes display when a note is created
this.onNoteAdded?.()
return newNote.id
}
/**
* Update the text content of a note
*/
updateText(noteId: string, text: string): void {
const notes = this.getNotes()
const updatedNotes = notes.map((note) => (note.id === noteId ? { ...note, text } : note))
this.setNotes(updatedNotes)
}
/**
* Update the color of a note
*/
updateColor(noteId: string, color: NoteColor): void {
const notes = this.getNotes()
const updatedNotes = notes.map((note) => (note.id === noteId ? { ...note, color } : note))
this.setNotes(updatedNotes)
}
/**
* Update the position of a note
*/
updatePosition(noteId: string, position: { x: number; y: number }): void {
const notes = this.getNotes()
const updatedNotes = notes.map((note) => (note.id === noteId ? { ...note, position } : note))
this.setNotes(updatedNotes)
}
/**
* Update the size of a note
*/
updateSize(noteId: string, size: { width: number; height: number }): void {
const notes = this.getNotes()
const updatedNotes = notes.map((note) => (note.id === noteId ? { ...note, size } : note))
this.setNotes(updatedNotes)
}
/**
* Toggle the locked state of a note
*/
updateLock(noteId: string, locked: boolean): void {
const notes = this.getNotes()
const updatedNotes = notes.map((note) => (note.id === noteId ? { ...note, locked } : note))
this.setNotes(updatedNotes)
}
/**
* Delete a note from the flow
*/
deleteNote(noteId: string): void {
const notes = this.getNotes()
const updatedNotes = notes.filter((note) => note.id !== noteId)
this.setNotes(updatedNotes)
}
/**
* Find which nodes from the given list are already in existing group notes
*/
private findNodesInExistingGroups(nodeIds: string[]): {
overlappingGroups: FlowNote[]
nodesInGroups: Set<string>
} {
const notes = this.getNotes()
const groupNotes = notes.filter((note) => note.type === 'group')
const overlappingGroups: FlowNote[] = []
const nodesInGroups = new Set<string>()
for (const groupNote of groupNotes) {
const containedNodeIds = groupNote.contained_node_ids || []
const hasOverlap = nodeIds.some((nodeId) => containedNodeIds.includes(nodeId))
if (hasOverlap) {
overlappingGroups.push(groupNote)
containedNodeIds.forEach((nodeId) => nodesInGroups.add(nodeId))
}
}
return { overlappingGroups, nodesInGroups }
}
/**
* Get smart color for group note based on existing groups
*/
private getSmartGroupNoteColor(nodeIds: string[]): NoteColor {
const { overlappingGroups } = this.findNodesInExistingGroups(nodeIds)
// If no overlapping groups, use default color
if (overlappingGroups.length === 0) {
return DEFAULT_GROUP_NOTE_COLOR
}
// Get colors used by overlapping groups
const usedColors = new Set<NoteColor>()
overlappingGroups.forEach((group) => {
if (group.color) {
usedColors.add(group.color as NoteColor)
}
})
// Return next available color
return getNextAvailableColor(usedColors)
}
/**
* Create a group note containing the specified node IDs
*/
createGroupNote(
nodeIds: string[],
text: string = '### Group note\nDouble click to edit me'
): string {
// Filter ids in case they contain subflow nodes
let filteredNodeIds: string[] = nodeIds
let subflowIds: string[] = []
for (const id of nodeIds) {
if (id.startsWith('subflow:')) {
const match = id.match(/^subflow:([^:]+)/)
if (match) {
subflowIds.push(match[1])
}
}
}
if (subflowIds.length > 0) {
filteredNodeIds = filteredNodeIds.filter((id) => !subflowIds.includes(id))
filteredNodeIds = [...filteredNodeIds, ...subflowIds]
}
// Position and size will be calculated dynamically by layout
const smartColor = this.getSmartGroupNoteColor(filteredNodeIds)
const groupNote: Omit<FlowNote, 'id'> = {
text,
color: smartColor,
type: 'group',
contained_node_ids: filteredNodeIds,
locked: false
}
return this.addNote(groupNote)
}
/**
* Check if a node is the only member of an existing group note
*/
isNodeOnlyMemberOfGroupNote(nodeId: string): boolean {
const notes = this.getNotes()
const groupNotes = notes.filter((note) => note.type === 'group')
for (const groupNote of groupNotes) {
const containedNodeIds = groupNote.contained_node_ids || []
if (containedNodeIds.length === 1 && containedNodeIds.includes(nodeId)) {
return true
}
}
return false
}
/**
* Check if editing is available (flowStore is properly initialized)
*/
isAvailable(): boolean {
return !!this.flowStore.val.value
}
/**
* Clean up group notes using DAG path completion
*/
cleanupGroupNotes(flowNodes: { id: string; parentIds?: string[]; offset?: number }[]): void {
if (!this.isAvailable()) {
return
}
const allNotes = this.getNotes()
const groupNotes = allNotes.filter((note) => note.type === 'group')
if (groupNotes.length === 0) return
let hasChanges = false
const nodeSet = new Set(flowNodes.map((n) => n.id))
// Step 1: Clean invalid nodes from existing group notes
for (const note of groupNotes) {
const originalIds = note.contained_node_ids || []
const validIds = originalIds.filter((id) => nodeSet.has(id))
if (validIds.length !== originalIds.length) {
note.contained_node_ids = validIds
hasChanges = true
}
}
// Step 2: Complete paths for each group using the DAG algorithm
const splitGroups: FlowNote[] = []
for (const note of groupNotes) {
const originalNodes = note.contained_node_ids || []
if (originalNodes.length === 0) continue
// Use the DAG path completion and splitting algorithm
const completedGroups = completeAndSplitGroup(originalNodes, flowNodes)
if (completedGroups.length <= 1) {
// Single group or no change needed
const completeNodes = completedGroups.length > 0 ? completedGroups[0] : []
const sortedComplete = completeNodes.sort()
const sortedOriginal = originalNodes.sort()
if (
sortedComplete.length !== sortedOriginal.length ||
!sortedComplete.every((id, i) => id === sortedOriginal[i])
) {
note.contained_node_ids = completeNodes
hasChanges = true
}
} else {
// Multiple groups - split into separate notes
hasChanges = true
// Mark original note for removal
note.contained_node_ids = []
// Create new notes for each completed group
for (const completedGroup of completedGroups) {
splitGroups.push({
...note,
id: generateId(),
contained_node_ids: completedGroup
})
}
}
}
// Remove empty group notes and add split component notes
const nonEmptyGroupNotes = groupNotes.filter(
(note) => (note.contained_node_ids?.length || 0) > 0
)
if (hasChanges || splitGroups.length > 0) {
const updatedNotes = [
...allNotes.filter((note) => note.type !== 'group'),
...nonEmptyGroupNotes,
...splitGroups
]
this.setNotes(updatedNotes)
}
}
}
/**
* Context type for NoteEditor
*/
export type NoteEditorContext = {
noteEditor: NoteEditor
}
const CONTEXT_KEY = 'NoteEditorContext'
/**
* Set the NoteEditor context (used in FlowBuilder)
*/
export function setNoteEditorContext(noteEditor: NoteEditor): void {
setContext<NoteEditorContext>(CONTEXT_KEY, { noteEditor })
}
/**
* Get the NoteEditor context (used in components that need editing capabilities)
*/
export function getNoteEditorContext(): NoteEditorContext | undefined {
return getContext<NoteEditorContext | undefined>(CONTEXT_KEY)
}
@@ -0,0 +1,150 @@
import type { FlowNote } from '$lib/gen'
import type { Node } from '@xyflow/svelte'
import { getLayoutSignature, getPropertySignature } from './noteUtils.svelte'
import { deepEqual } from 'fast-equals'
import { untrack } from 'svelte'
/**
* Utility class for managing flow note text height caching, selection, and fine-grained reactivity
* Handles both fast visual updates and structural changes
*/
export class NoteManager {
renderCount = $state(0)
// Track notes for layout change detection
#notes: () => FlowNote[]
#previousLayoutSignature: ReturnType<typeof getLayoutSignature> = $state({
notesCount: 0,
noteIds: [],
groupMemberships: []
})
#previousPropertySignature: ReturnType<typeof getPropertySignature> = $state([])
// Function to update nodes array with reactivity
#setNodes: (nodes: Node[]) => void
#getNodes: () => Node[]
// Selection state
#selectedNoteId = $state<string | undefined>(undefined)
constructor(notes: () => FlowNote[], setNodes: (nodes: Node[]) => void, getNodes: () => Node[]) {
this.#notes = notes
this.#setNodes = setNodes
this.#getNodes = getNodes
// Effect to monitor note changes with dual signature tracking
$effect(() => {
const currentNotes = this.#notes()
const currentLayoutSignature = getLayoutSignature(currentNotes)
const currentPropertySignature = getPropertySignature(currentNotes)
untrack(() => {
const hasLayoutChanges = !deepEqual(currentLayoutSignature, this.#previousLayoutSignature)
const hasPropertyChanges = !deepEqual(
currentPropertySignature,
this.#previousPropertySignature
)
if (hasLayoutChanges) {
// Structural changes require full re-render
this.#previousLayoutSignature = currentLayoutSignature
this.#previousPropertySignature = currentPropertySignature
this.render()
} else if (hasPropertyChanges) {
// Property changes can be handled with fast updates
this.#updateNodesProperties(currentNotes)
this.#previousPropertySignature = currentPropertySignature
}
})
})
}
/**
* Triggers a re-render of the graph by incrementing the render count
*/
render(): void {
this.renderCount++
}
/**
* Update node properties using setter function for proper reactivity
* Only updates visual properties that don't affect layout
*/
#updateNodesProperties(currentNotes: FlowNote[]): void {
const currentNodes = this.#getNodes()
if (currentNodes.length === 0) return
// Create a new array with updated nodes to trigger reactivity
const updatedNodes = currentNodes.map((node) => {
const note = currentNotes.find((n) => n.id === node.id)
if (!note || node.type !== 'note') return node
// Clone the node to avoid mutation
const updatedNode = { ...node, data: { ...node.data } }
// Update properties that don't affect layout
if (updatedNode.data) {
updatedNode.data.text = note.text
updatedNode.data.color = note.color
updatedNode.data.locked = note.locked || false
}
// Update draggable property based on lock state
const isGroupNote = note.type === 'group'
updatedNode.draggable = isGroupNote ? false : !note.locked
// Update free note size and position (group notes are calculated differently)
if (!isGroupNote && note.size && note.position) {
updatedNode.width = note.size.width
updatedNode.height = note.size.height
updatedNode.position = { ...note.position }
}
return updatedNode
})
// Use setter function to trigger reactivity
this.#setNodes(updatedNodes)
}
/**
* Select a note by ID (single selection only)
*/
selectNote(noteId: string): void {
if (this.#selectedNoteId === noteId) {
return
}
this.#selectedNoteId = noteId
}
/**
* Clear note selection
*/
clearNoteSelection(): void {
this.#selectedNoteId = undefined
}
/**
* Deselect a note by ID (single selection only)
*/
deselectNote(noteId?: string): void {
if (this.#selectedNoteId === noteId) {
this.#selectedNoteId = undefined
}
}
/**
* Check if a note is currently selected
*/
isNoteSelected(noteId: string): boolean {
return this.#selectedNoteId === noteId
}
// Handle keyboard shortcuts
handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
// Escape key clears selection regardless of mode
this.clearNoteSelection()
}
}
}
@@ -0,0 +1,420 @@
import type { FlowNote } from '$lib/gen'
import type { Node } from '@xyflow/svelte'
import { deepEqual } from 'fast-equals'
import { calculateNodesBoundsWithOffset } from './util'
import { MIN_NOTE_WIDTH, MIN_NOTE_HEIGHT } from './noteColors'
import type { NodeLayout } from './graphBuilder.svelte'
import { topologicalSort } from './graphBuilder.svelte'
import type { AssetWithAltAccessType } from '../assets/lib'
import type { NoteEditorContext } from './noteEditor.svelte'
import { StickyNote } from 'lucide-svelte'
export type NodeDep = {
id: string
position: { x: number; y: number }
data?: { assets?: AssetWithAltAccessType[] }
parentIds?: string[]
offset?: number
type?: string
}
export type NoteComputeResult = {
noteNodes: (Node & NodeLayout)[]
newNodePositions: Record<string, { x: number; y: number }>
}
export type AIToolSpacingInfo = {
toolNodes: (Node & NodeLayout)[]
toolEdges: any[]
newNodePositions: Record<string, { x: number; y: number }>
}
export interface GroupNoteBounds {
x: number
y: number
width: number
height: number
}
let computeNoteNodesCache:
| [NodeDep[], FlowNote[], Record<string, number>, NoteComputeResult]
| undefined
/**
* Extracts layout-affecting signature for change detection
* Only includes properties that affect graph layout (structure, grouping)
*/
export function getLayoutSignature(notes: FlowNote[]) {
return {
notesCount: notes.length,
noteIds: notes.map((n) => n.id).sort(),
// Group memberships affect layout spacing
groupMemberships: notes
.filter((note) => note.type === 'group')
.map((note) => ({
id: note.id,
containedIds: note.contained_node_ids?.slice().sort() || []
}))
.sort((a, b) => a.id.localeCompare(b.id))
}
}
/**
* Extracts property-only signature for change detection
* Only includes visual/content properties that don't affect layout
*/
export function getPropertySignature(notes: FlowNote[]) {
return notes
.map((note) => ({
id: note.id,
text: note.text,
color: note.color,
locked: note.locked || false,
position: { ...note.position },
size: { ...note.size }
}))
.sort((a, b) => a.id.localeCompare(b.id))
}
/**
* Calculates z-index values for all notes
* Group notes are ordered by their topmost node's hierarchy position
* Free notes get undefined z-index to use SvelteFlow's native behavior
*/
export function calculateAllNoteZIndexes(
notes: FlowNote[],
nodes: NodeDep[]
): Record<string, number | undefined> {
const zIndexMap: Record<string, number | undefined> = {}
// Use topological sort to get proper hierarchy order based on parentIds relationships
const sortedNodes = topologicalSort(nodes).reverse()
// Create a mapping from node ID to its hierarchy position (topological order)
const nodeHierarchyMap: Record<string, number> = {}
sortedNodes.forEach((node, index) => {
nodeHierarchyMap[node.id] = index
})
// Process each note
for (const note of notes) {
if (note.type === 'free') {
// Free notes use SvelteFlow's native z-index behavior (last selected on top)
zIndexMap[note.id] = undefined
} else if (note.type === 'group') {
// Group notes get z-index based on topmost contained node's hierarchy
// Since sortedNodes is in topological order, the first matching node is the topmost
const topmostNode = sortedNodes.find((node) => note.contained_node_ids?.includes(node.id))
if (topmostNode) {
const hierarchyPosition = nodeHierarchyMap[topmostNode.id] ?? 0
// Higher hierarchy position = lower z-index (appears behind)
// Use negative values starting from -2000 to stay below other elements
zIndexMap[note.id] = hierarchyPosition - 2000
} else {
// Fallback for group notes without valid contained nodes
zIndexMap[note.id] = -2000
}
}
}
return zIndexMap
}
/**
* Calculate extra spacing needed for asset nodes of the topmost node
*/
function calculateExtraAssetSpacing(topmostNodeId: string, nodes: NodeDep[]): number {
// Find the topmost node position
const topmostNode = nodes.find((n) => n.id === topmostNodeId)
if (!topmostNode) {
return 0
}
// Find actual asset nodes for the topmost node: {topmostNodeId}-asset-in, type 'asset'
const assetNodes = nodes.filter((n) => n.id.startsWith(`${topmostNodeId}-asset-in-`))
if (assetNodes.length === 0) {
return 0
}
// Calculate the spacing based on actual asset node positions
const assetSpacing = Math.max(
...assetNodes.map((assetNode) => {
// Calculate how much space the asset node takes above the main node
return Math.max(0, -assetNode.position.y)
})
)
return assetSpacing
}
/**
* Calculate extra spacing needed for AI tool nodes of the topmost node
*/
function calculateExtraAIToolSpacing(topmostNodeId: string, nodes: NodeDep[]): number {
// Find the topmost node position
const topmostNode = nodes.find((n) => n.id === topmostNodeId)
if (!topmostNode) {
return 0
}
// Find actual AI tool nodes for the topmost node: {topmostNodeId}-tool-, type 'aiTool'
const toolNodes = nodes.filter((n) => n.id.startsWith(`${topmostNodeId}-tool-`))
if (toolNodes.length === 0) {
return 0
}
// Calculate the spacing based on actual AI tool node positions
const toolSpacing = Math.max(
...toolNodes.map((toolNode) => {
// Calculate how much space the tool node takes above/below the main node
return Math.max(0, -toolNode.position.y)
})
)
return toolSpacing
}
/**
* Calculate position and size for group notes based on contained nodes
*/
function calculateGroupNoteLayout(
note: FlowNote,
nodes: NodeDep[],
textHeight: number = 60,
topMostNodeId: string
): { position: { x: number; y: number }; size: { width: number; height: number } } {
if (note.type !== 'group' || !note.contained_node_ids?.length) {
return {
position: note.position ?? { x: 0, y: 0 },
size: note.size ?? { width: MIN_NOTE_WIDTH, height: MIN_NOTE_HEIGHT }
}
}
const containedNodes = nodes.filter((node) => note.contained_node_ids?.includes(node.id))
if (containedNodes.length === 0) {
return {
position: note.position ?? { x: 0, y: 0 },
size: note.size ?? { width: MIN_NOTE_WIDTH, height: MIN_NOTE_HEIGHT }
}
}
const bounds = calculateNodesBoundsWithOffset(
note.contained_node_ids || [],
nodes.map((n) => ({
id: n.id,
position: n.position,
data: { offset: n.offset ?? 0 },
type: n.type ?? ''
}))
)
const padding = 16
// Calculate extra spacing for asset nodes and AI tool nodes of the topmost node
const extraAssetSpacing = topMostNodeId ? calculateExtraAssetSpacing(topMostNodeId, nodes) : 0
const extraAIToolSpacing = topMostNodeId ? calculateExtraAIToolSpacing(topMostNodeId, nodes) : 0
const totalTextHeight = textHeight + extraAssetSpacing + extraAIToolSpacing
return {
position: {
x: bounds.minX - padding,
y: bounds.minY - totalTextHeight - padding
},
size: {
width: bounds.maxX - bounds.minX + 2 * padding,
height: bounds.maxY - bounds.minY + totalTextHeight + 2 * padding
}
}
}
/**
* Create common data object for note nodes
*/
function createNoteData(
note: FlowNote,
onTextHeightChange: (noteId: string, height: number) => void,
isGroupNote: boolean,
editMode: boolean
) {
return {
noteId: note.id,
text: note.text,
color: note.color,
locked: note.locked || false,
isGroupNote,
editMode,
...(isGroupNote && { containedNodeIds: note.contained_node_ids || [] }),
onTextHeightChange: (textHeight: number) => {
onTextHeightChange(note.id, textHeight)
}
}
}
/**
* Main function to compute note nodes and adjust nodes position based on group notes
*/
export function computeNoteNodes(
nodes: NodeDep[],
notes: FlowNote[],
noteTextHeights: Record<string, number>,
onTextHeightChange: (noteId: string, height: number) => void,
editMode: boolean = false,
noteEditorContext: NoteEditorContext | undefined
): NoteComputeResult {
// Check cache first
if (
computeNoteNodesCache &&
deepEqual(nodes, computeNoteNodesCache[0]) &&
deepEqual(notes, computeNoteNodesCache[1]) &&
deepEqual(noteTextHeights, computeNoteNodesCache[2])
) {
return computeNoteNodesCache[3]
}
if (editMode) {
if (noteEditorContext?.noteEditor?.isAvailable()) {
noteEditorContext.noteEditor.cleanupGroupNotes(nodes)
}
}
const allNoteNodes: (Node & NodeLayout)[] = []
// Build a map of Y positions that need extra spacing for group notes
const yPosMap: Record<number, number> = {} // Y position -> spacing needed
// Group notes that need spacing
const groupNotes = notes.filter((n) => n.type === 'group')
const topMostNodesMap: Record<string, string> = {}
const sortedNodes = topologicalSort(nodes).reverse()
for (const groupNote of groupNotes) {
if (groupNote.contained_node_ids?.length) {
const topmostNodeId = sortedNodes.find((node) =>
groupNote.contained_node_ids?.includes(node.id)
)?.id
const topmostNode = nodes.find((node) => node.id === topmostNodeId)
if (topmostNode) {
const textHeight = noteTextHeights[groupNote.id] || 60
const spacing = textHeight + 16 // padding
// Mark this Y position as needing spacing
yPosMap[topmostNode.position.y] = Math.max(yPosMap[topmostNode.position.y] || 0, spacing)
topMostNodesMap[groupNote.id] = topmostNode.id
}
}
}
// Calculate new positions for nodes (offset by group notes)
const sortedNewNodes = nodes
.map((n) => ({ position: { ...n.position }, id: n.id }))
.sort((a, b) => a.position.y - b.position.y)
let currentYOffset = 0
let prevYPos = NaN
for (const node of sortedNewNodes) {
if (node.position.y !== prevYPos) {
// Add spacing for group notes at this Y level
if (yPosMap[node.position.y]) {
currentYOffset += yPosMap[node.position.y]
}
prevYPos = node.position.y
}
node.position.y += currentYOffset
}
// Create note nodes AFTER calculating adjusted node positions
// For group notes, we need to use the adjusted node positions
const adjustedNodes = sortedNewNodes.map((n) => {
const origNode = nodes.find((orig) => orig.id === n.id)
return {
...n,
data: origNode?.data,
offset: origNode?.offset,
type: origNode?.type
}
})
// Calculate all z-indexes at once using hierarchy information
const noteZIndexes = calculateAllNoteZIndexes(notes, nodes)
for (const note of notes) {
const isGroupNote = note.type === 'group'
const zIndex = noteZIndexes[note.id]
// Calculate position and size using adjusted node positions for group notes
const { position, size } = isGroupNote
? calculateGroupNoteLayout(
note,
adjustedNodes,
noteTextHeights[note.id] || 60,
topMostNodesMap[note.id]
)
: {
position: note.position ?? { x: 0, y: 0 },
size: note.size ?? { width: MIN_NOTE_WIDTH, height: MIN_NOTE_HEIGHT }
}
// Create the note node
const noteNode: Node & NodeLayout = {
id: note.id,
type: 'note' as any, // Note nodes are handled specially
position,
width: size.width,
height: size.height,
zIndex,
draggable: isGroupNote ? false : editMode && !note.locked,
selectable: false,
data: createNoteData(note, onTextHeightChange, isGroupNote, editMode) as any
}
allNoteNodes.push(noteNode)
}
const newNodePositions: Record<string, { x: number; y: number }> = Object.fromEntries(
sortedNewNodes.map((n) => [n.id, n.position])
)
const result: NoteComputeResult = {
noteNodes: allNoteNodes,
newNodePositions
}
// Cache the result
computeNoteNodesCache = [
structuredClone($state.snapshot(nodes)),
structuredClone($state.snapshot(notes)),
structuredClone($state.snapshot(noteTextHeights)),
result
]
return result
}
export function addGroupNoteContextMenuItem(
nodeId: string,
noteEditorContext: NoteEditorContext | undefined
) {
const isDisabled =
!noteEditorContext?.noteEditor ||
(noteEditorContext?.noteEditor?.isNodeOnlyMemberOfGroupNote(nodeId) ?? false)
return {
id: 'add-group-note',
label: 'Add note',
icon: StickyNote,
disabled: isDisabled,
onClick: () => {
if (noteEditorContext?.noteEditor && !isDisabled) {
noteEditorContext.noteEditor.createGroupNote([nodeId])
}
}
}
}
@@ -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({
@@ -172,7 +172,8 @@
(agentActions
? Math.floor(i / MAX_TOOLS_PER_ROW) + 1
: totalRows - Math.floor(i / MAX_TOOLS_PER_ROW))
}
},
selectable: false
}
})
@@ -181,7 +182,8 @@
source: agentActions ? (n.parentId ?? '') : (n.id ?? ''),
target: agentActions ? (n.id ?? '') : (n.parentId ?? ''),
type: 'empty',
data: { class: '!opacity-35 dark:!opacity-20' }
data: { class: '!opacity-35 dark:!opacity-20' },
selectable: false
}))
allToolEdges.push(...(toolEdges ?? []))
@@ -197,7 +199,8 @@
position: {
x: (ROW_WIDTH - NEW_TOOL_NODE_WIDTH) / 2 + node.data.offset,
y: baseOffset + rowOffset
}
},
selectable: false
} satisfies Node & NewAiToolN)
}
}
@@ -253,13 +256,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 +271,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 +322,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 }, '')}
@@ -86,7 +86,8 @@
? (-ASSETS_OVERFLOWED_NODE_WIDTH - inputAssetXGap) / 2
: 0),
y: READ_ASSET_Y_OFFSET
}
},
selectable: false
}
})
@@ -122,7 +123,8 @@
? (-ASSETS_OVERFLOWED_NODE_WIDTH - outputAssetXGap) / 2
: 0),
y: WRITE_ASSET_Y_OFFSET
}
},
selectable: false
}
})
@@ -2,12 +2,15 @@
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']
id: string
}
let { data }: Props = $props()
let { data, id }: Props = $props()
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset} enableSourceHandle enableTargetHandle>
@@ -16,7 +19,7 @@
label={'Collect result from all branches'}
id={data.id}
selectable={true}
selected={false}
selected={selectionManager && selectionManager.isNodeSelected(id)}
on:select={(e) => {
data?.eventHandlers?.select(e.detail)
}}
@@ -5,12 +5,15 @@
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']
id: string
}
let { data }: Props = $props()
let { data, id }: Props = $props()
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset}>
@@ -18,7 +21,7 @@
<VirtualItem
label={data.label}
selectable
selected={false}
selected={selectionManager && selectionManager.isNodeSelected(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']
id: string
}
const { selectionManager } = getGraphContext()
let { data }: Props = $props()
let { data, id }: Props = $props()
</script>
<NodeWrapper offset={data.offset}>
@@ -19,7 +21,7 @@
label={data.label}
preLabel={data.preLabel}
selectable
selected={data.selected}
selected={selectionManager && selectionManager.isNodeSelected(id)}
on:select={() => {
setTimeout(() => data?.eventHandlers?.select(data.id))
}}
@@ -2,12 +2,15 @@
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']
id: string
}
let { data }: Props = $props()
let { data, id }: Props = $props()
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset}>
@@ -27,7 +30,7 @@
<VirtualItem
label={'Collect result of each iteration'}
selectable={true}
selected={false}
selected={selectionManager && selectionManager.isNodeSelected(id)}
id={data.id}
on:select={(e) => {
setTimeout(() => data?.eventHandlers?.select(e.detail))
@@ -5,12 +5,15 @@
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']
id: string
}
let { data }: Props = $props()
let { data, id }: Props = $props()
const { selectionManager } = getGraphContext()
const propPickerContext = getContext<PropPickerContext>('PropPickerContext')
const pickablePropertiesFiltered = propPickerContext?.pickablePropertiesFiltered
@@ -58,7 +61,7 @@
<VirtualItem
label={data.simplifiedTriggerView ? 'For each new event' : 'Do one iteration'}
selectable={false}
selected={false}
selected={selectionManager && selectionManager.isNodeSelected(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))
}}
@@ -4,6 +4,9 @@
import NodeWrapper from './NodeWrapper.svelte'
import type { ModuleN } from '../../graphBuilder.svelte'
import { jobToGraphModuleState } from '$lib/components/modulesTest.svelte'
import { getNoteEditorContext } from '../../noteEditor.svelte'
import type { ContextMenuItem } from '../../../common/contextmenu/ContextMenu.svelte'
import { addGroupNoteContextMenuItem } from '../../noteUtils.svelte'
interface Props {
data: ModuleN['data']
@@ -11,6 +14,9 @@
let { data }: Props = $props()
// Get NoteEditor context for group note creation
const noteEditorContext = getNoteEditorContext()
let state = $derived.by(() => {
return data.testModuleState
? (jobToGraphModuleState(data.testModuleState) ?? data.flowModuleState)
@@ -35,9 +41,14 @@
}
return typ
})
// Define context menu items
const contextMenuItems: ContextMenuItem[] = $derived(
data.editMode ? [addGroupNoteContextMenuItem(data.id, noteEditorContext)] : []
)
</script>
<NodeWrapper offset={data.offset}>
<NodeWrapper offset={data.offset} {contextMenuItems}>
{#snippet children({ darkMode })}
<MapItem
moduleId={data.id}
@@ -2,7 +2,6 @@
import VirtualItem from '$lib/components/flows/map/VirtualItem.svelte'
import NodeWrapper from './NodeWrapper.svelte'
import type { NoBranchN } from '../../graphBuilder.svelte'
interface Props {
data: NoBranchN['data']
}
@@ -2,12 +2,14 @@
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
import { Handle, Position } from '@xyflow/svelte'
import { twMerge } from 'tailwind-merge'
import ContextMenu, { type ContextMenuItem } from '../../../common/contextmenu/ContextMenu.svelte'
interface Props {
enableSourceHandle?: boolean
enableTargetHandle?: boolean
offset?: number
wrapperClass?: string
contextMenuItems?: ContextMenuItem[]
children?: import('svelte').Snippet<[any]>
}
@@ -16,6 +18,7 @@
enableTargetHandle = true,
offset = 0,
wrapperClass = '',
contextMenuItems = undefined,
children
}: Props = $props()
@@ -24,24 +27,38 @@
<DarkModeObserver bind:darkMode />
<div class={twMerge('relative rounded-md', wrapperClass)} style={`margin-left: ${offset}px;`}>
{@render children?.({ darkMode })}
</div>
{#if contextMenuItems && contextMenuItems.length > 0}
<ContextMenu items={contextMenuItems}>
<div class={twMerge('relative rounded-md', wrapperClass)} style={`margin-left: ${offset}px;`}>
{@render children?.({ darkMode })}
</div>
{#if enableSourceHandle}
<Handle
type="source"
isConnectable={false}
position={Position.Bottom}
style={`margin-left: ${offset / 2}px;`}
/>
{@render handles()}
</ContextMenu>
{:else}
<div class={twMerge('relative rounded-md', wrapperClass)} style={`margin-left: ${offset}px;`}>
{@render children?.({ darkMode })}
</div>
{@render handles()}
{/if}
{#if enableTargetHandle}
<Handle
type="target"
isConnectable={false}
position={Position.Top}
style={`margin-left: ${offset / 2}px;`}
/>
{/if}
{#snippet handles()}
{#if enableSourceHandle}
<Handle
type="source"
isConnectable={false}
position={Position.Bottom}
style={`margin-left: ${offset / 2}px;`}
/>
{/if}
{#if enableTargetHandle}
<Handle
type="target"
isConnectable={false}
position={Position.Top}
style={`margin-left: ${offset / 2}px;`}
/>
{/if}
{/snippet}
@@ -0,0 +1,368 @@
<script lang="ts">
import { NodeResizer, ViewportPortal, useSvelteFlow } from '@xyflow/svelte'
import { X, Lock, LockOpen } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import { fade } from 'svelte/transition'
import NoteColorPicker from '../../NoteColorPicker.svelte'
import {
NoteColor,
NOTE_COLORS,
DEFAULT_NOTE_COLOR,
MIN_NOTE_WIDTH,
MIN_NOTE_HEIGHT
} from '../../noteColors'
import { Button } from '$lib/components/common'
import { getNoteEditorContext } from '../../noteEditor.svelte'
import { getGraphContext } from '../../graphContext'
import { clickOutside } from '$lib/utils'
import { tick } from 'svelte'
interface Props {
data: {
noteId: string
text: string
color: NoteColor
locked?: boolean
isGroupNote?: boolean
editMode?: boolean
// Callback for layout calculations (needed in both edit and view modes)
onTextHeightChange?: (height: number) => void
}
dragging?: boolean
}
let { data, dragging = false }: Props = $props()
// Get SvelteFlow utilities for accessing node position
const { getNode } = useSvelteFlow()
// Get NoteEditor context for edit mode
const noteEditorContext = getNoteEditorContext()
const isEditModeAvailable = $derived(!!noteEditorContext?.noteEditor && data.editMode)
// Get graph context for note selection
const graphContext = getGraphContext()
const noteManager = graphContext?.noteManager
const selected = $derived(noteManager?.isNoteSelected(data.noteId) ?? false)
// Get the current node with position data
const currentNode = $derived(getNode(data.noteId))
let textareaElement: HTMLTextAreaElement | undefined = $state(undefined)
let editMode = $state(false)
let hovering = $state(false)
let textContent = $state(data.text ?? '')
let containerHeight = $state(0)
// Use data props directly - they're kept in sync by NoteManager observer
const color = $derived(data.color ?? DEFAULT_NOTE_COLOR)
const locked = $derived(data.locked ?? false)
const textForDisplay = $derived(data.text ?? '')
function handleTextSave() {
// Only update when done editing
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
noteEditorContext.noteEditor.updateText(data.noteId, textContent)
}
}
function handleDelete(event?: Event) {
event?.preventDefault?.()
event?.stopPropagation?.()
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
noteEditorContext.noteEditor.deleteNote(data.noteId)
}
}
function handleColorChange(color: NoteColor) {
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
noteEditorContext.noteEditor.updateColor(data.noteId, color)
}
}
function handleLockToggle(event?: Event) {
event?.preventDefault?.()
event?.stopPropagation?.()
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
noteEditorContext.noteEditor.updateLock(data.noteId, !locked)
}
}
// Get color configuration for current color
const colorConfig = $derived(NOTE_COLORS[color])
function handleDoubleClick(event: Event) {
event.preventDefault()
event.stopPropagation()
// Don't allow editing if note is locked or edit mode is not available
if (locked || !isEditModeAvailable) {
return
}
editMode = true
// Focus the textarea after a short delay to ensure it's rendered
tick().then(() => {
textareaElement?.focus()
})
}
function handleMouseEnter() {
hovering = true
}
function handleMouseLeave() {
hovering = false
}
// Exit edit mode when note is deselected
$effect(() => {
if (!selected && editMode) {
editMode = false
}
})
let colorPickerIsOpen = $state(false)
function handleNoteClick(event: MouseEvent) {
// Only handle selection if not in edit mode and not dragging
if (!editMode && !dragging && noteManager) {
event.stopPropagation()
noteManager.selectNote(data.noteId)
}
}
function handleNoteKeydown(event: KeyboardEvent) {
// Handle Enter or Space key for selection (accessibility)
if ((event.key === 'Enter' || event.key === ' ') && !editMode && !dragging && noteManager) {
event.preventDefault()
event.stopPropagation()
noteManager.selectNote(data.noteId)
}
}
function handleTextareaKeydown(event: KeyboardEvent) {
// Handle Escape key to exit edit mode
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
handleTextSave() // Save changes before exiting
editMode = false
textareaElement?.blur() // Remove focus from textarea
}
}
</script>
<!-- Snippet for action buttons to avoid code duplication -->
{#snippet actionButtons()}
<div
class={twMerge(
'hidden group-hover:flex flex-row gap-2 h-fit',
hovering || editMode || colorPickerIsOpen || selected ? 'flex' : ''
)}
>
<!-- Lock/Unlock button -->
<Button
variant="subtle"
unifiedSize="xs"
title={locked ? 'Unlock note' : 'Lock note'}
aria-label={locked ? 'Unlock note' : 'Lock note'}
startIcon={{ icon: locked ? Lock : LockOpen }}
onClick={handleLockToggle}
iconOnly
/>
<!-- Color picker -->
{#if !locked}
<NoteColorPicker
selectedColor={color}
onColorChange={handleColorChange}
bind:isOpen={colorPickerIsOpen}
/>
{/if}
<!-- Delete button -->
{#if !locked}
<Button
variant="subtle"
unifiedSize="xs"
title="Delete note"
aria-label="Delete note"
startIcon={{ icon: X }}
onClick={handleDelete}
iconOnly
destructive
/>
{/if}
</div>
{/snippet}
<div
class={twMerge(
'relative w-full h-full rounded-md group hover:outline outline-1',
colorConfig.background,
colorConfig.text,
colorConfig.outlineHover,
selected ? 'outline' : '',
selected ? colorConfig.outline : '',
editMode ? 'outline-0' : ''
)}
onclick={handleNoteClick}
onkeydown={handleNoteKeydown}
onpointerup={() => {
dragging = false
}}
ondragstart={() => {
dragging = true
}}
ondragend={() => {
dragging = false
}}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
role="button"
tabindex={editMode ? -1 : 0}
ondblclick={handleDoubleClick}
use:clickOutside={{
onClickOutside: () => {
noteManager?.deselectNote(data.noteId)
}
}}
>
<!-- Hover help text -->
{#if hovering || selected}
{#if !editMode && isEditModeAvailable}
<div
in:fade={{ duration: 200 }}
class="absolute -top-5 h-5 left-0 text-2xs text-secondary rounded-md z-10 transition-opacity duration-300"
>
{locked
? 'Note is locked'
: isEditModeAvailable
? 'Double click to edit'
: 'View only mode'}
</div>
{:else if !locked && isEditModeAvailable}
<div
in:fade={{ duration: 200 }}
class="absolute -top-5 h-5 left-0 text-2xs text-secondary rounded-md z-10 transition-opacity duration-300"
>GH Markdown</div
>
{/if}
{/if}
<!-- Note content -->
<div
class={twMerge(
'w-full rounded-md ',
data.isGroupNote ? 'min-h-[60px] max-h-[400px]' : 'h-full'
)}
>
{#if editMode}
<!-- Edit mode: show textarea -->
<textarea
bind:this={textareaElement}
bind:value={textContent}
class={twMerge(
'windmillapp w-full shadow-none resize-none text-xs overflow-y-auto border-none rounded-md bg-transparent transition-colors p-4 nodrag nowheel',
colorConfig.text
)}
placeholder="Double click to edit me"
onblur={handleTextSave}
onkeydown={handleTextareaKeydown}
spellcheck="false"
style:height={data.isGroupNote ? `${containerHeight > 0 ? containerHeight : 60}px` : '100%'}
></textarea>
{:else}
<!-- Render mode: show markdown or empty state -->
<div
class={twMerge(
'w-full h-fit overflow-auto cursor-pointer flex items-start justify-start rounded-md p-4'
)}
bind:clientHeight={
() => containerHeight,
(v) => {
if (v > 0 && v !== containerHeight) {
data.onTextHeightChange?.(v)
}
containerHeight = v
}
}
>
{#if textForDisplay}
<div
class={twMerge(
'w-full text-xs rounded-md break-words overflow-hidden',
colorConfig.text
)}
>
<GfmMarkdown md={textForDisplay} noPadding />
</div>
{:else}
<div class={twMerge('text-xs italic opacity-60', colorConfig.text)}>
Double click to edit me
</div>
{/if}
</div>
{/if}
</div>
<!-- Node resizer - only visible when selected and not locked and edit mode is available -->
{#if !locked && isEditModeAvailable}
<NodeResizer
isVisible={selected && !dragging && !data.isGroupNote}
minWidth={MIN_NOTE_WIDTH}
minHeight={MIN_NOTE_HEIGHT}
lineClass="!border-4 !border-transparent !rounded-md"
handleClass="!bg-transparent !w-4 !h-4 !border-none !rounded-md"
onResizeEnd={(_, params) => {
// Update note size when resizing ends
if (params.width !== undefined && params.height !== undefined) {
const size = { width: params.width, height: params.height }
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
// Use NoteEditor context in edit mode
noteEditorContext.noteEditor.updateSize(data.noteId, size)
}
}
}}
/>
{/if}
<!-- Action buttons - conditional rendering based on note type -->
{#if isEditModeAvailable}
{#if data.isGroupNote && currentNode?.position}
<!-- Group notes: Use ViewportPortal to render above graph edges -->
<ViewportPortal target="front">
<div
class="absolute p-1 w-24 h-8 group flex justify-end"
style:transform="translate({currentNode.position.x +
(currentNode.measured?.width ?? MIN_NOTE_WIDTH) -
100}px , {currentNode.position.y - 30}px)"
style:pointer-events="auto"
style:z-index="1000"
>
{@render actionButtons()}
</div>
</ViewportPortal>
{:else}
<!-- Standalone notes: Use normal absolute positioning -->
<div class="absolute -top-8 -right-2.5 p-2 w-32 h-12 group flex justify-end">
{@render actionButtons()}
</div>
{/if}
{/if}
</div>
<style>
textarea::placeholder {
color: #6b7280;
opacity: 0.7;
}
/* Remove default textarea styling */
textarea {
font-family: inherit;
line-height: 1.4;
}
</style>
@@ -1,19 +1,17 @@
<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']
id: string
}
let { data }: Props = $props()
let { data, id }: Props = $props()
const { selectedId } = getContext<{
selectedId: Writable<string | undefined>
}>('FlowGraphContext')
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper enableSourceHandle={false}>
@@ -22,7 +20,7 @@
id={'Result'}
label={'Result'}
selectable={true}
selected={$selectedId === 'Result'}
selected={selectionManager && selectionManager.isNodeSelected(id)}
hideId={true}
on:select={(e) => {
setTimeout(() => data?.eventHandlers?.select(e.detail))
@@ -5,12 +5,16 @@
import NodeWrapper from './NodeWrapper.svelte'
import { Minimize2 } from 'lucide-svelte'
import type { SubflowBoundN } from '../../graphBuilder.svelte'
import { getGraphContext } from '../../graphContext'
interface Props {
data: SubflowBoundN['data']
id: string
}
let { data }: Props = $props()
let { data, id }: Props = $props()
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset}>
@@ -19,7 +23,7 @@
label={data.label}
preLabel={data.preLabel}
selectable
selected={data.selected}
selected={selectionManager && selectionManager.isNodeSelected(id)}
on:select={() => {
setTimeout(() => data.eventHandlers?.select(data.id))
}}
@@ -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,15 @@
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']
id: string
}
let { data }: Props = $props()
const { selectionManager } = getGraphContext()
let { data, id }: Props = $props()
</script>
<NodeWrapper offset={data.offset}>
@@ -16,7 +19,7 @@
label={'Collect result from chosen branch'}
id={data.id}
selectable={true}
selected={false}
selected={selectionManager?.isNodeSelected(id)}
on:select={(e) => {
setTimeout(() => data?.eventHandlers?.select(e.detail))
}}
@@ -0,0 +1,106 @@
import type { Node } from '@xyflow/svelte'
export class SelectionManager {
#selectedNodes = $state<Node[] | { id: string }[]>([])
#selectionMode = $state<'normal' | 'rect-select'>('normal')
#clearGraphSelection: () => void = () => {}
constructor() {}
setClearGraphSelection(clearGraphSelection: () => void) {
this.#clearGraphSelection = clearGraphSelection
}
selectId(id: string) {
if (this.#selectedNodes.length === 1 && this.#selectedNodes[0].id === id) {
return
}
this.#clearGraphSelection()
this.#selectedNodes = [{ id }]
}
getSelectedId(): string {
if (this.#selectedNodes.length === 0) {
return 'settings'
}
const selectedNode = this.#selectedNodes[0]
if (selectedNode['type'] === 'branchOneEnd') {
const id = selectedNode.id.replace(/-end$/, '')
if (id !== '') {
return id
}
} else if (selectedNode['type'] === 'branchAllEnd') {
const id = selectedNode.id.replace(/-end$/, '')
if (id !== '') {
return id
}
} else if (selectedNode['type'] === 'forLoopStart') {
const id = selectedNode.id.replace(/-start$/, '')
if (id !== '') {
return id
}
} else if (selectedNode['type'] === 'forLoopEnd') {
const id = selectedNode.id.replace(/-end$/, '')
if (id !== '') {
return id
}
} else if (selectedNode['type'] === 'subflowBound') {
const id = selectedNode.id.replace(/-subflow-end$/, '')
if (id !== '') {
return id
}
}
return selectedNode.id
}
get mode() {
return this.#selectionMode
}
set mode(mode: 'normal' | 'rect-select') {
this.#selectionMode = mode
}
get selectedIds() {
if (this.#selectedNodes.length === 0) {
return ['settings']
}
return [...this.#selectedNodes.map((node) => node.id)]
}
// Select nodes with optional hierarchical selection
selectNodes(nodes: Node[]) {
// Guard against empty nodeIds or uninitialized state
if (!nodes || nodes.length === 0) {
this.clearSelection()
return
}
// If the new selection is the same as the current selection, do nothing
if (JSON.stringify(nodes) === JSON.stringify($state.snapshot(this.#selectedNodes))) {
return
}
this.#selectedNodes = nodes
}
// Clear all selections
clearSelection() {
this.#selectedNodes = [{ id: 'settings' }]
}
// Check if a node is selected
isNodeSelected(nodeId: string): boolean {
return this.#selectedNodes.some((node) => node.id === nodeId)
}
// Handle keyboard shortcuts
handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
// Escape key clears selection regardless of mode
this.clearSelection()
this.#clearGraphSelection()
}
}
}
+101
View File
@@ -15,6 +15,9 @@ export type FlowNodeColorClasses = {
outline: string
badge: string
}
export const AI_OR_ASSET_NODE_TYPES = ['asset', 'assetsOverflowed', 'newAiTool', 'aiTool']
export type FlowNodeState = FlowStatusModule['type'] | '_VirtualItem' | '_Skipped' | undefined
export function getNodeColorClasses(state: FlowNodeState, selected: boolean): FlowNodeColorClasses {
@@ -126,3 +129,101 @@ export function getNodeColorClasses(state: FlowNodeState, selected: boolean): Fl
return r
}
/**
* Calculate the bounding box for a collection of nodes, accounting for CSS offset
* Also includes expanded subflow nodes when calculating bounds for subflow containers
* @param containedIds - Array of node IDs to calculate bounds for
* @param allNodes - Array of all nodes to search for expanded subflow nodes
* @returns The bounds { minX, minY, maxX, maxY }
*/
export function calculateNodesBoundsWithOffset(
containedIds: string[],
allNodes: Array<{
id: string
position: { x: number; y: number }
data?: { offset?: number }
type: string
}>
): {
minX: number
minY: number
maxX: number
maxY: number
} {
// Find related subflow nodes
const nodesToCalculate = getAllRelatedSubflowNodes(containedIds, allNodes)
return nodesToCalculate.reduce(
(acc, node) => {
// Account for CSS offset applied by NodeWrapper
const cssOffset = node.data?.offset ?? 0
const visualX = node.position.x + cssOffset
return {
minX: Math.min(acc.minX, visualX),
minY: Math.min(acc.minY, node.position.y),
maxX: Math.max(acc.maxX, visualX + NODE.width),
maxY: Math.max(acc.maxY, node.position.y + NODE.height)
}
},
{
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity
}
)
}
/**
* Find all nodes related to the given node IDs, including expanded subflow nodes
* @param targetNodeIds - Array of node IDs to find related nodes for
* @param allNodes - Array of all available nodes
* @returns Array of nodes including original nodes and any related subflow nodes
*/
function getAllRelatedSubflowNodes(
targetNodeIds: string[],
allNodes: Array<{
id: string
position: { x: number; y: number }
data?: { offset?: number }
type: string
}>
): Array<{
id: string
position: { x: number; y: number }
data?: { offset?: number }
}> {
const relatedNodeIds = new Set<string>()
// Add original target nodes
targetNodeIds.forEach((id) => relatedNodeIds.add(id))
// For each target node, check if it's a subflow and find expanded nodes
targetNodeIds.forEach((nodeId) => {
// Find nodes like "subflow:{nodeId}:*"
const subflowNodes = allNodes.filter(
(node) =>
node.id.startsWith(`subflow:${nodeId}:`) && !AI_OR_ASSET_NODE_TYPES.includes(node.type)
)
// Find end node like "{nodeId}-subflow-end"
const endNode = allNodes.find((node) => node.id === `${nodeId}-subflow-end`)
// Add all found nodes
subflowNodes.forEach((node) => relatedNodeIds.add(node.id))
if (endNode) relatedNodeIds.add(endNode.id)
})
// Return actual node objects that exist in allNodes
return allNodes.filter((node) => relatedNodeIds.has(node.id))
}
/**
* Generate a random unique ID for notes
* @returns A random string ID
*/
export function generateId(): string {
return 'note-' + Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2)
}
@@ -23,6 +23,11 @@
import { ButtonType } from '$lib/components/common/button/model'
export const inputSizeClasses = {
xs: twMerge(
ButtonType.UnifiedSizingClasses.xs,
ButtonType.UnifiedMinHeightClasses.xs,
'px-1 !py-0.5'
),
sm: twMerge(
ButtonType.UnifiedSizingClasses.sm,
ButtonType.UnifiedMinHeightClasses.sm,
@@ -42,7 +47,7 @@
value?: string
class?: string
error?: string | boolean
size?: 'sm' | 'md' | 'lg'
size?: ButtonType.UnifiedSize
unifiedHeight?: boolean
}
@@ -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()
@@ -8,6 +8,8 @@
import ClearableInput from '$lib/components/common/clearableInput/ClearableInput.svelte'
import DateInput from '$lib/components/DateInput.svelte'
import DateTimeInput from '$lib/components/DateTimeInput.svelte'
import ContextMenu from '$lib/components/common/contextmenu/ContextMenu.svelte'
import { StickyNote } from 'lucide-svelte'
import {
Pen,
GitFork,
@@ -152,6 +154,45 @@
disabled: true
}
]
// Context menu items
const contextMenuItems = [
{
id: 'create-note',
label: 'Create sticky note',
icon: StickyNote,
onClick: () => console.log('Create sticky note clicked')
},
{
id: 'edit',
label: 'Edit item',
icon: Pen,
onClick: () => console.log('Edit item clicked')
},
{
id: 'divider-1',
label: '',
divider: true
},
{
id: 'copy',
label: 'Copy',
icon: Copy,
onClick: () => console.log('Copy clicked')
},
{
id: 'share',
label: 'Share',
icon: Share,
onClick: () => console.log('Share clicked')
},
{
id: 'delete',
label: 'Delete',
icon: Trash,
onClick: () => console.log('Delete clicked')
}
]
</script>
<div class="p-8 flex flex-col gap-8 max-w-6xl mx-auto bg-surface-secondary min-h-screen pb-32">
@@ -578,6 +619,64 @@
</div>
</div>
<!-- CONTEXT MENU SECTION HEADER -->
<header class="flex flex-col gap-2 border-b border-gray-200 dark:border-gray-700 pb-4 mt-12">
<h2 class="text-lg font-semibold text-emphasis">Context Menus</h2>
<p class="text-xs text-secondary"> Right-click triggered menus with contextual actions. </p>
</header>
<!-- Context Menu Examples -->
<div class="flex flex-col gap-6">
<h3 class="text-sm font-semibold text-emphasis">Context menu tests</h3>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div
class="flex flex-col space-y-4 p-6 border border-gray-200 dark:border-gray-700 rounded-lg bg-surface"
>
<div class="space-y-1">
<h4 class="text-xs font-semibold text-emphasis">Basic Context Menu</h4>
<p class="text-xs text-secondary">Right-click the area below</p>
</div>
<ContextMenu items={contextMenuItems}>
<div
class="w-full h-32 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg flex items-center justify-center text-sm text-secondary bg-surface-tertiary cursor-pointer hover:border-accent hover:text-accent transition-colors"
>
Right-click me for context menu
</div>
</ContextMenu>
</div>
<div
class="flex flex-col space-y-4 p-6 border border-gray-200 dark:border-gray-700 rounded-lg bg-surface"
>
<div class="space-y-1">
<h4 class="text-xs font-semibold text-emphasis">Text Context Menu</h4>
<p class="text-xs text-secondary">Right-click the text below</p>
</div>
<ContextMenu items={contextMenuItems}>
<div class="p-4 border rounded-lg bg-surface-tertiary">
<p class="text-sm">
Right-click this text to open the context menu. You can test various interactions
here.
</p>
</div>
</ContextMenu>
</div>
<div
class="flex flex-col space-y-4 p-6 border border-gray-200 dark:border-gray-700 rounded-lg bg-surface"
>
<div class="space-y-1">
<h4 class="text-xs font-semibold text-emphasis">Button Context Menu</h4>
<p class="text-xs text-secondary">Right-click the button below</p>
</div>
<ContextMenu items={contextMenuItems}>
<Button variant="accent" size="sm">Right-click this button</Button>
</ContextMenu>
</div>
</div>
</div>
<!-- INPUT SECTION HEADER -->
<header class="flex flex-col gap-2 border-b border-gray-200 dark:border-gray-700 pb-4 mt-12">
<h2 class="text-lg font-semibold text-emphasis">Input Components</h2>
+2 -1
View File
@@ -4,7 +4,7 @@
import { decodeState } from '$lib/utils'
let content = localStorage.getItem('svelvet')
const { modules, failureModule, preprocessorModule } = content
const { modules, failureModule, preprocessorModule, notes } = content
? decodeState(content)
: { modules: [], failureModule: undefined, preprocessorModule: undefined }
</script>
@@ -15,6 +15,7 @@
{modules}
{failureModule}
{preprocessorModule}
{notes}
/>
<a
download="flow.json"
+39 -36
View File
@@ -186,6 +186,45 @@ const config = {
900: `rgb(${primitives['purple-900']})`,
950: `rgb(${primitives['purple-950']})`
},
pink: {
50: `rgb(${primitives['pink-50']})`,
100: `rgb(${primitives['pink-100']})`,
200: `rgb(${primitives['pink-200']})`,
300: `rgb(${primitives['pink-300']})`,
400: `rgb(${primitives['pink-400']})`,
500: `rgb(${primitives['pink-500']})`,
600: `rgb(${primitives['pink-600']})`,
700: `rgb(${primitives['pink-700']})`,
800: `rgb(${primitives['pink-800']})`,
900: `rgb(${primitives['pink-900']})`,
950: `rgb(${primitives['pink-950']})`
},
lime: {
50: `rgb(${primitives['lime-50']})`,
100: `rgb(${primitives['lime-100']})`,
200: `rgb(${primitives['lime-200']})`,
300: `rgb(${primitives['lime-300']})`,
400: `rgb(${primitives['lime-400']})`,
500: `rgb(${primitives['lime-500']})`,
600: `rgb(${primitives['lime-600']})`,
700: `rgb(${primitives['lime-700']})`,
800: `rgb(${primitives['lime-800']})`,
900: `rgb(${primitives['lime-900']})`,
950: `rgb(${primitives['lime-950']})`
},
yellow: {
50: `rgb(${primitives['yellow-50']})`,
100: `rgb(${primitives['yellow-100']})`,
200: `rgb(${primitives['yellow-200']})`,
300: `rgb(${primitives['yellow-300']})`,
400: `rgb(${primitives['yellow-400']})`,
500: `rgb(${primitives['yellow-500']})`,
600: `rgb(${primitives['yellow-600']})`,
700: `rgb(${primitives['yellow-700']})`,
800: `rgb(${primitives['yellow-800']})`,
900: `rgb(${primitives['yellow-900']})`,
950: `rgb(${primitives['yellow-950']})`
},
slate: {
50: '#f8fafc',
100: '#f1f5f9',
@@ -246,18 +285,6 @@ const config = {
800: '#92400e',
900: '#78350f'
},
lime: {
50: '#f7fee7',
100: '#ecfccb',
200: '#d9f99d',
300: '#bef264',
400: '#a3e635',
500: '#84cc16',
600: '#65a30d',
700: '#4d7c0f',
800: '#3f6212',
900: '#365314'
},
emerald: {
50: '#ecfdf5',
100: '#d1fae5',
@@ -330,18 +357,6 @@ const config = {
800: '#86198f',
900: '#701a75'
},
pink: {
50: '#fdf2f8',
100: '#fce7f3',
200: '#fbcfe8',
300: '#f9a8d4',
400: '#f472b6',
500: '#ec4899',
600: '#db2777',
700: '#be185d',
800: '#9d174d',
900: '#831843'
},
rose: {
50: '#fff1f2',
100: '#ffe4e6',
@@ -366,18 +381,6 @@ const config = {
800: '#1f2937',
900: '#111827'
},
yellow: {
50: '#fefce8',
100: '#fef9c3',
200: '#fef08a',
300: '#fde047',
400: '#facc15',
500: '#eab308',
600: '#ca8a04',
700: '#a16207',
800: '#854d0e',
900: '#713f12'
},
indigo: {
50: '#eef2ff',
100: '#e0e7ff',
+63
View File
@@ -73,6 +73,11 @@ components:
chat_input_enabled:
type: boolean
description: Whether this flow accepts chat-style input
notes:
type: array
description: Sticky notes attached to the flow
items:
$ref: "#/components/schemas/FlowNote"
required:
- modules
@@ -103,6 +108,64 @@ components:
retry_if:
$ref: "#/components/schemas/RetryIf"
FlowNote:
type: object
description: A sticky note attached to a flow for documentation and annotation
properties:
id:
type: string
description: Unique identifier for the note
text:
type: string
description: Content of the note
position:
type: object
description: Position of the note in the flow editor
properties:
x:
type: number
description: X coordinate
y:
type: number
description: Y coordinate
required:
- x
- y
size:
type: object
description: Size of the note in the flow editor
properties:
width:
type: number
description: Width in pixels
height:
type: number
description: Height in pixels
required:
- width
- height
color:
type: string
description: Color of the note (e.g., "yellow", "#ffff00")
type:
type: string
enum: [free, group]
description: Type of note - 'free' for standalone notes, 'group' for notes that group other nodes
locked:
type: boolean
default: false
description: Whether the note is locked and cannot be edited or moved
contained_node_ids:
type: array
items:
type: string
description: For group notes, the IDs of nodes contained within this group
required:
- id
- text
- color
- type
RetryIf:
type: object
properties: