From 24f9115dc06a4dcd826d1ebd4e46db3191d35cd0 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 19 Nov 2025 21:25:57 +0000 Subject: [PATCH] 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 d2c40d82b1db3701bbfb609d5704d9580c5a73e3. * refert backend changes * nit * improve graph selection * clean * make backend work except job runs * fix notSelectable --------- Co-authored-by: Ruben Fiszel --- ...8d1e383aeda9a5a71183b7fbaa41deca4e333.json | 2 +- ...f4a5b962f7803def9ecd6e33d2aec6abce772.json | 2 +- backend/windmill-api/openapi.yaml | 2 + backend/windmill-api/src/flows.rs | 22 +- backend/windmill-common/src/cache.rs | 15 + backend/windmill-common/src/flows.rs | 2 +- .../windmill-worker/src/worker_lockfiles.rs | 32 +- frontend/src/lib/assets/tokens/tokens.json | 41 +- frontend/src/lib/components/Dev.svelte | 24 +- .../src/lib/components/FlowBuilder.svelte | 55 ++- .../src/lib/components/FlowGraphViewer.svelte | 3 +- .../lib/components/FlowPreviewContent.svelte | 6 +- .../components/FlowStatusViewerInner.svelte | 7 +- .../src/lib/components/GfmMarkdown.svelte | 9 +- .../components/common/button/Button.svelte | 2 +- .../src/lib/components/common/button/model.ts | 10 +- .../common/contextmenu/ContextMenu.svelte | 126 ++++++ .../common/contextmenu/contextMenuStyles.ts | 45 ++ .../lib/components/copilot/IteratorGen.svelte | 6 +- .../components/copilot/PredicateGen.svelte | 6 +- .../components/copilot/StepInputGen.svelte | 6 +- .../components/copilot/StepInputsGen.svelte | 6 +- .../copilot/chat/AIChatManager.svelte.ts | 2 +- .../copilot/chat/flow/FlowAIChat.svelte | 20 +- .../lib/components/flows/FlowEditor.svelte | 4 + .../flows/content/FlowEditorPanel.svelte | 31 +- .../flows/content/FlowModuleComponent.svelte | 20 +- .../flows/content/FlowModuleSleep.svelte | 4 +- .../flows/content/FlowModuleSuspend.svelte | 4 +- .../content/FlowModuleWorkerTagSelect.svelte | 4 +- .../flows/content/FlowModuleWrapper.svelte | 29 +- .../flows/content/FlowSelectionPanel.svelte | 44 ++ .../flows/header/FlowPreviewButtons.svelte | 10 +- .../flows/map/FlowErrorHandlerItem.svelte | 10 +- .../flows/map/FlowModuleSchemaItem.svelte | 2 +- .../flows/map/FlowModuleSchemaMap.svelte | 45 +- .../flows/map/FlowStickyNode.svelte | 30 +- .../lib/components/flows/map/MapItem.svelte | 23 +- frontend/src/lib/components/flows/types.ts | 5 +- .../lib/components/graph/FlowGraphV2.svelte | 395 ++++++++++++++-- .../components/graph/NodeContextMenu.svelte | 47 ++ .../components/graph/NoteColorPicker.svelte | 50 +++ .../src/lib/components/graph/NoteTool.svelte | 216 +++++++++ .../components/graph/PaneContextMenu.svelte | 115 +++++ .../graph/SelectionBoundingBox.svelte | 82 ++++ .../lib/components/graph/SelectionTool.svelte | 43 ++ .../components/graph/graphBuilder.svelte.ts | 15 +- .../src/lib/components/graph/graphContext.ts | 19 + .../components/graph/groupDetectionUtils.ts | 86 ++++ .../src/lib/components/graph/noteColors.ts | 135 ++++++ .../lib/components/graph/noteEditor.svelte.ts | 322 ++++++++++++++ .../components/graph/noteManager.svelte.ts | 150 +++++++ .../lib/components/graph/noteUtils.svelte.ts | 420 ++++++++++++++++++ .../graph/renderers/edges/BaseEdge.svelte | 8 +- .../graph/renderers/edges/EmptyEdge.svelte | 7 +- .../graph/renderers/nodes/AIToolNode.svelte | 20 +- .../graph/renderers/nodes/AssetNode.svelte | 6 +- .../renderers/nodes/BranchAllEndNode.svelte | 9 +- .../renderers/nodes/BranchAllStart.svelte | 9 +- .../renderers/nodes/BranchOneStart.svelte | 8 +- .../renderers/nodes/ForLoopEndNode.svelte | 9 +- .../renderers/nodes/ForLoopStartNode.svelte | 9 +- .../graph/renderers/nodes/InputNode.svelte | 9 +- .../graph/renderers/nodes/ModuleNode.svelte | 13 +- .../graph/renderers/nodes/NoBranchNode.svelte | 1 - .../graph/renderers/nodes/NodeWrapper.svelte | 53 ++- .../graph/renderers/nodes/NoteNode.svelte | 368 +++++++++++++++ .../graph/renderers/nodes/ResultNode.svelte | 12 +- .../graph/renderers/nodes/SubflowBound.svelte | 8 +- .../graph/renderers/nodes/TriggersNode.svelte | 57 ++- .../renderers/nodes/branchOneEndNode.svelte | 9 +- .../components/graph/selectionUtils.svelte.ts | 106 +++++ frontend/src/lib/components/graph/util.ts | 101 +++++ .../components/text_input/TextInput.svelte | 7 +- .../FlowBuilderTutorialForLoop.svelte | 4 +- frontend/src/routes/flows/dev/+page.svelte | 8 +- .../routes/test_dev/kitchen_sink/+page.svelte | 99 +++++ frontend/src/routes/view_graph/+page.svelte | 3 +- frontend/tailwind.config.cjs | 75 ++-- openflow.openapi.yaml | 63 +++ 80 files changed, 3538 insertions(+), 354 deletions(-) create mode 100644 frontend/src/lib/components/common/contextmenu/ContextMenu.svelte create mode 100644 frontend/src/lib/components/common/contextmenu/contextMenuStyles.ts create mode 100644 frontend/src/lib/components/flows/content/FlowSelectionPanel.svelte create mode 100644 frontend/src/lib/components/graph/NodeContextMenu.svelte create mode 100644 frontend/src/lib/components/graph/NoteColorPicker.svelte create mode 100644 frontend/src/lib/components/graph/NoteTool.svelte create mode 100644 frontend/src/lib/components/graph/PaneContextMenu.svelte create mode 100644 frontend/src/lib/components/graph/SelectionBoundingBox.svelte create mode 100644 frontend/src/lib/components/graph/SelectionTool.svelte create mode 100644 frontend/src/lib/components/graph/graphContext.ts create mode 100644 frontend/src/lib/components/graph/groupDetectionUtils.ts create mode 100644 frontend/src/lib/components/graph/noteColors.ts create mode 100644 frontend/src/lib/components/graph/noteEditor.svelte.ts create mode 100644 frontend/src/lib/components/graph/noteManager.svelte.ts create mode 100644 frontend/src/lib/components/graph/noteUtils.svelte.ts create mode 100644 frontend/src/lib/components/graph/renderers/nodes/NoteNode.svelte create mode 100644 frontend/src/lib/components/graph/selectionUtils.svelte.ts diff --git a/backend/.sqlx/query-544a00afb5c72d2aa24ab4ccfe68d1e383aeda9a5a71183b7fbaa41deca4e333.json b/backend/.sqlx/query-544a00afb5c72d2aa24ab4ccfe68d1e383aeda9a5a71183b7fbaa41deca4e333.json index bf1418bb0d..8c0d13fbd1 100644 --- a/backend/.sqlx/query-544a00afb5c72d2aa24ab4ccfe68d1e383aeda9a5a71183b7fbaa41deca4e333.json +++ b/backend/.sqlx/query-544a00afb5c72d2aa24ab4ccfe68d1e383aeda9a5a71183b7fbaa41deca4e333.json @@ -13,4 +13,4 @@ "nullable": [] }, "hash": "544a00afb5c72d2aa24ab4ccfe68d1e383aeda9a5a71183b7fbaa41deca4e333" -} +} \ No newline at end of file diff --git a/backend/.sqlx/query-d2acc380c45df31f741bbd420c2f4a5b962f7803def9ecd6e33d2aec6abce772.json b/backend/.sqlx/query-d2acc380c45df31f741bbd420c2f4a5b962f7803def9ecd6e33d2aec6abce772.json index a5329c6a9b..a13d62402a 100644 --- a/backend/.sqlx/query-d2acc380c45df31f741bbd420c2f4a5b962f7803def9ecd6e33d2aec6abce772.json +++ b/backend/.sqlx/query-d2acc380c45df31f741bbd420c2f4a5b962f7803def9ecd6e33d2aec6abce772.json @@ -22,4 +22,4 @@ "nullable": [] }, "hash": "d2acc380c45df31f741bbd420c2f4a5b962f7803def9ecd6e33d2aec6abce772" -} +} \ No newline at end of file diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index fac07d1abb..39e23a74db 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 diff --git a/backend/windmill-api/src/flows.rs b/backend/windmill-api/src/flows.rs index dd141d46f6..a2e8e307e6 100644 --- a/backend/windmill-api/src/flows.rs +++ b/backend/windmill-api/src/flows.rs @@ -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 "#, diff --git a/backend/windmill-common/src/cache.rs b/backend/windmill-common/src/cache.rs index c7972d3a7e..df0ef6842a 100644 --- a/backend/windmill-common/src/cache.rs +++ b/backend/windmill-common/src/cache.rs @@ -285,6 +285,21 @@ pub struct FlowData { pub flow: FlowValue, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct FlowNotes { + pub notes: Option>, +} + +impl FlowData { + pub fn notes(&self) -> Option { + serde_json::from_str::(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 { diff --git a/backend/windmill-common/src/flows.rs b/backend/windmill-common/src/flows.rs index 5b81d35f75..27044c1dd3 100644 --- a/backend/windmill-common/src/flows.rs +++ b/backend/windmill-common/src/flows.rs @@ -194,7 +194,7 @@ pub struct FlowValue { #[serde(skip_serializing_if = "Option::is_none")] pub chat_input_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub flow_env: Option>> + pub flow_env: Option>>, } impl FlowValue { diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index 0b840884f5..cc92955569 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -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>, // TODO: Make this a Vec + } + + 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!( diff --git a/frontend/src/lib/assets/tokens/tokens.json b/frontend/src/lib/assets/tokens/tokens.json index f04c2432f3..d4749e0e2d 100644 --- a/frontend/src/lib/assets/tokens/tokens.json +++ b/frontend/src/lib/assets/tokens/tokens.json @@ -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" } }, diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index 86ded3bc0d..1b6df2a06f 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -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(undefined) const modulesTestStates = new ModulesTestStates((moduleId) => { // console.log('FOO') @@ -508,7 +511,7 @@ let pathStore = writable('') let initialPathStore = writable('') setContext('FlowEditorContext', { - selectedId: selectedIdStore, + selectionManager, previewArgs: previewArgsStore, scriptEditorDrawer, moving, @@ -538,6 +541,13 @@ pickablePropertiesFiltered: writable(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 = $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()) @@ -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() diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 2ed847bff4..daaa4d5238 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -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(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(false) @@ -607,7 +617,7 @@ let flowEditor: FlowEditor | undefined = $state(undefined) setContext('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} { - 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 diff --git a/frontend/src/lib/components/FlowGraphViewer.svelte b/frontend/src/lib/components/FlowGraphViewer.svelte index 5297ed3584..c54935299d 100644 --- a/frontend/src/lib/components/FlowGraphViewer.svelte +++ b/frontend/src/lib/components/FlowGraphViewer.svelte @@ -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') { diff --git a/frontend/src/lib/components/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index 751f412a11..ec6d02f0dd 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -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 - {$selectedId} + {selectionManager.getSelectedId()} {:else} Test flow diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index 4a573bfcc5..120c7cb9a1 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -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 = $state({}) - let selectedId = writable(selectedNode) + let selectionManager = new SelectionManager() function onFlowModuleId() { let modId = flowJobIds?.moduleId @@ -1730,7 +1730,7 @@ {/each} -
+
diff --git a/frontend/src/lib/components/common/button/Button.svelte b/frontend/src/lib/components/common/button/Button.svelte index ee6a3265de..b1372dfd0b 100644 --- a/frontend/src/lib/components/common/button/Button.svelte +++ b/frontend/src/lib/components/common/button/Button.svelte @@ -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}` } diff --git a/frontend/src/lib/components/common/button/model.ts b/frontend/src/lib/components/common/button/model.ts index 5eb75ba531..e1af83c971 100644 --- a/frontend/src/lib/components/common/button/model.ts +++ b/frontend/src/lib/components/common/button/model.ts @@ -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 = { + xs: 'px-1', sm: 'px-2', // Regular horizontal padding md: 'px-4', lg: 'px-6' } export const UnifiedIconOnlySizingClasses: Record = { + 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 = { + xs: 'min-h-5', sm: 'min-h-7', md: 'min-h-8', lg: 'min-h-10' } export const UnifiedHeightClasses: Record = { + xs: 'h-5', sm: 'h-7', md: 'h-8', lg: 'h-10' } export const UnifiedIconSizes: Record = { + xs: 12, sm: 13, md: 14, lg: 18 } export const UnifiedFontSizes: Record = { + xs: 'font-normal', sm: 'font-normal', md: 'font-medium', lg: 'font-medium' diff --git a/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte b/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte new file mode 100644 index 0000000000..8e0f440bde --- /dev/null +++ b/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte @@ -0,0 +1,126 @@ + + +
+ {@render children?.()} +
+ +{#if $open} +
+ {#each items as menuItem (menuItem.id)} + {#if menuItem.divider} +
+ {:else} +
handleItemClick(menuItem)} + > + {#if menuItem.icon} + + {/if} + {#if menu} + {@render menu({ item: menuItem })} + {:else} + {menuItem.label} + {/if} +
+ {/if} + {/each} +
+{/if} diff --git a/frontend/src/lib/components/common/contextmenu/contextMenuStyles.ts b/frontend/src/lib/components/common/contextmenu/contextMenuStyles.ts new file mode 100644 index 0000000000..ecbe6c6f87 --- /dev/null +++ b/frontend/src/lib/components/common/contextmenu/contextMenuStyles.ts @@ -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' + diff --git a/frontend/src/lib/components/copilot/IteratorGen.svelte b/frontend/src/lib/components/copilot/IteratorGen.svelte index 46eb6d1519..b9015eb2b1 100644 --- a/frontend/src/lib/components/copilot/IteratorGen.svelte +++ b/frontend/src/lib/components/copilot/IteratorGen.svelte @@ -35,7 +35,7 @@ ) let abortController = new AbortController() - const { flowStore, selectedId } = getContext('FlowEditorContext') + const { flowStore, selectionManager } = getContext('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: diff --git a/frontend/src/lib/components/copilot/PredicateGen.svelte b/frontend/src/lib/components/copilot/PredicateGen.svelte index 6bfeacd62c..c49edb70a8 100644 --- a/frontend/src/lib/components/copilot/PredicateGen.svelte +++ b/frontend/src/lib/components/copilot/PredicateGen.svelte @@ -29,7 +29,7 @@ }) let abortController = $state(new AbortController()) - const { flowStore, selectedId } = getContext('FlowEditorContext') + const { flowStore, selectionManager } = getContext('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: diff --git a/frontend/src/lib/components/copilot/StepInputGen.svelte b/frontend/src/lib/components/copilot/StepInputGen.svelte index 9dec547627..d2a29cfa8c 100644 --- a/frontend/src/lib/components/copilot/StepInputGen.svelte +++ b/frontend/src/lib/components/copilot/StepInputGen.svelte @@ -54,7 +54,7 @@ let abortController = new AbortController() let newFlowInput = $state('') - const { flowStore, selectedId } = getContext('FlowEditorContext') + const { flowStore, selectionManager } = getContext('FlowEditorContext') const { stepInputsLoading, generatedExprs } = getContext('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. diff --git a/frontend/src/lib/components/copilot/StepInputsGen.svelte b/frontend/src/lib/components/copilot/StepInputsGen.svelte index 2ce8d4b177..c2781153a6 100644 --- a/frontend/src/lib/components/copilot/StepInputsGen.svelte +++ b/frontend/src/lib/components/copilot/StepInputsGen.svelte @@ -30,7 +30,7 @@ let { pickableProperties = undefined, argNames = [], schema = undefined }: Props = $props() - const { flowStore, selectedId } = getContext('FlowEditorContext') + const { flowStore, selectionManager } = getContext('FlowEditorContext') const { exprsToSet, stepInputsLoading, generatedExprs } = getContext('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( diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index edeeed1af9..149f6930d8 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -882,7 +882,7 @@ class AIChatManager { } listenForSelectedIdChanges = ( - selectedId: string, + selectedId: string | undefined, flowStore: ExtendedOpenFlow, flowStateStore: FlowState, currentEditor: CurrentEditor diff --git a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte index dc48253240..56c7f18f7c 100644 --- a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte @@ -25,8 +25,9 @@ flowModuleSchemaMap: FlowModuleSchemaMap | undefined } = $props() - const { flowStore, flowStateStore, selectedId, currentEditor } = + const { flowStore, flowStateStore, selectionManager, currentEditor } = getContext('FlowEditorContext') + const selectedId = $derived(selectionManager.getSelectedId()) const { exprsToSet } = getContext('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() } }) diff --git a/frontend/src/lib/components/flows/FlowEditor.svelte b/frontend/src/lib/components/flows/FlowEditor.svelte index 775bd7cadb..0d7be9c358 100644 --- a/frontend/src/lib/components/flows/FlowEditor.svelte +++ b/frontend/src/lib/components/flows/FlowEditor.svelte @@ -102,6 +102,10 @@ return flowModuleSchemaMap?.isNodeVisible(nodeId) ?? false } + export function enableNotes(): void { + flowModuleSchemaMap?.enableNotes?.() + } + setContext('PropPickerContext', { flowPropPickerConfig: writable(undefined), pickablePropertiesFiltered: writable(undefined) diff --git a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte index 3e6d4e22af..f0288e6533 100644 --- a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte +++ b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte @@ -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') + const selectedId = $derived(selectionManager.getSelectedId()) + const { showCaptureHint, triggersState, triggersCount } = getContext('TriggerContext') function checkDup(modules: FlowModule[]): string | undefined { @@ -84,14 +87,16 @@ }) -{#if $selectedId?.startsWith('settings')} +{#if selectionManager && selectionManager.selectedIds.length > 1} + +{:else if selectedId?.startsWith('settings')} -{:else if $selectedId === 'Input'} +{:else if selectedId === 'Input'} { - $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'} -{:else if $selectedId === 'constants'} +{:else if selectedId === 'constants'} -{:else if $selectedId === 'failure'} +{:else if selectedId === 'failure'} -{:else if $selectedId === 'preprocessor'} +{:else if selectedId === 'preprocessor'} -{:else if $selectedId === 'triggers'} +{:else if selectedId === 'Trigger'} { 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:')}
Selected step is witin an expanded subflow and is not directly editable in the flow editor
@@ -150,7 +155,7 @@ {#if dup}
There are duplicate modules in the flow at id: {dup}
{:else} - {#key $selectedId} + {#key selectedId} {#each flowStore.val.value.modules as flowModule, index (flowModule.id ?? index)} ('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')} { - $selectedId = 'settings-same-worker' + selectionManager.selectId('settings-same-worker') }} > Set shared directory in the flow settings diff --git a/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte b/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte index e9e15120fc..4a393893bf 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte @@ -20,7 +20,7 @@ let { flowModule = $bindable(), previousModuleId }: Props = $props() - const { selectedId, flowStore, flowStateStore, previewArgs } = + const { selectionManager, flowStore, flowStateStore, previewArgs } = getContext('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)) diff --git a/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte b/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte index fffc702a22..d1d946c5cd 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte @@ -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') - const result = flowStateStore.val[$selectedId]?.previewResult ?? {} + const { selectionManager, flowStateStore } = getContext('FlowEditorContext') + const result = flowStateStore.val[selectionManager.getSelectedId()]?.previewResult ?? {} let editor: SimpleEditor | undefined = $state(undefined) interface Props { diff --git a/frontend/src/lib/components/flows/content/FlowModuleWorkerTagSelect.svelte b/frontend/src/lib/components/flows/content/FlowModuleWorkerTagSelect.svelte index 21b689206c..d724f241e8 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleWorkerTagSelect.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleWorkerTagSelect.svelte @@ -17,7 +17,7 @@ noLabel?: boolean } = $props() - const { flowStore, selectedId } = getContext('FlowEditorContext') + const { flowStore, selectionManager } = getContext('FlowEditorContext') const dispatch = createEventDispatcher() loadWorkerGroups() @@ -44,7 +44,7 @@ diff --git a/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte b/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte index 8af071e7f5..cb50e8a327 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte @@ -23,7 +23,8 @@ import { formatCron } from '$lib/utils' import AgentToolWrapper from './AgentToolWrapper.svelte' - const { selectedId, flowStateStore } = getContext('FlowEditorContext') + const { selectionManager, flowStateStore } = getContext('FlowEditorContext') + const selectedId = $derived(selectionManager.getSelectedId()) const { triggersState, triggersCount } = getContext('TriggerContext') @@ -113,7 +114,7 @@ } -{#if flowModule.id === $selectedId} +{#if flowModule.id === selectedId} {#if flowModule.value.type === 'forloopflow'} {:else if flowModule.value.type === 'whileloopflow'} @@ -123,13 +124,13 @@ {:else if flowModule.value.type === 'branchall'} {:else if flowModule.value.type === 'identity'} - {#if $selectedId == 'failure'} + {#if selectedId == 'failure'}
If defined, the error handler will take the error as input.
- {:else if $selectedId == 'preprocessor'} + {:else if selectedId == 'preprocessor'}
{ 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`}

Default branch

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}`} {: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} + 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) + } + } + + + + {#snippet action()} + + {/snippet} +
+

{selectionManager.selectedIds.length} nodes selected

+
+ {#each selectionManager.selectedIds as nodeId} +
+ {nodeId} +
+ {/each} +
+
+
diff --git a/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte b/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte index d349b05500..64c4416d45 100644 --- a/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte +++ b/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte @@ -25,7 +25,7 @@ localModuleStates = $bindable({}) }: Props = $props() - const { selectedId } = getContext('FlowEditorContext') + const { selectionManager } = getContext('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} diff --git a/frontend/src/lib/components/flows/map/FlowErrorHandlerItem.svelte b/frontend/src/lib/components/flows/map/FlowErrorHandlerItem.svelte index 2a80a34f37..c9d681da50 100644 --- a/frontend/src/lib/components/flows/map/FlowErrorHandlerItem.svelte +++ b/frontend/src/lib/components/flows/map/FlowErrorHandlerItem.svelte @@ -29,7 +29,7 @@ generateStep: { moduleId: string; instructions: string; lang: ScriptLang } }>() - const { selectedId, flowStateStore, flowStore } = + const { selectionManager, flowStateStore, flowStore } = getContext('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') }} > diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte index 494715ad29..75a12d1e11 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte @@ -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} diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte index 0b0bcdb778..1b7af36c41 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte @@ -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') const { triggersCount, triggersState } = getContext('TriggerContext') const { flowPropPickerConfig } = getContext('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 = $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} />
@@ -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 />
diff --git a/frontend/src/lib/components/flows/map/FlowStickyNode.svelte b/frontend/src/lib/components/flows/map/FlowStickyNode.svelte index 48e7445593..b1821f98e3 100644 --- a/frontend/src/lib/components/flows/map/FlowStickyNode.svelte +++ b/frontend/src/lib/components/flows/map/FlowStickyNode.svelte @@ -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') + const { selectionManager, flowStore } = getContext('FlowEditorContext') + const selectedId = $derived(selectionManager.getSelectedId())
@@ -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} {/if} + + + {#snippet text()} + {noteMode ? 'Exit note mode' : 'Add sticky notes'} + {/snippet} +
diff --git a/frontend/src/lib/components/flows/map/MapItem.svelte b/frontend/src/lib/components/flows/map/MapItem.svelte index 2021b10e91..08d88f26aa 100644 --- a/frontend/src/lib/components/flows/map/MapItem.svelte +++ b/frontend/src/lib/components/flows/map/MapItem.svelte @@ -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 - }>('FlowGraphContext') + const { selectionManager } = getGraphContext() const { flowStore } = getContext('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) { + // Only handle left clicks (button 0) + if (e.detail.button === 0) { + onSelect(mod.id) + } + } {#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) => { diff --git a/frontend/src/lib/components/flows/types.ts b/frontend/src/lib/components/flows/types.ts index 733cd304aa..6ed09a1436 100644 --- a/frontend/src/lib/components/flows/types.ts +++ b/frontend/src/lib/components/flows/types.ts @@ -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 + selectionManager: SelectionManager currentEditor: Writable moving: Writable<{ id: string } | undefined> previewArgs: StateStore> diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 3b41cf2035..26a3ed2aed 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -1,7 +1,7 @@ {#if insertable} @@ -589,8 +815,9 @@ {/if}
{#if graph?.error}
@@ -615,14 +842,29 @@ bind:this={viewportSynchronizer} /> {/if} + { + 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} >
+ + {#if noteMode} + + {/if} + + {#if multiSelectEnabled} + + {/if} + + + + {#if leftHeader}
{@render leftHeader()}
{:else} + {#if multiSelectEnabled} +
+ + { + selectionManager.mode = + selectionManager.mode === 'normal' ? 'rect-select' : 'normal' + }} + > + {#if selectionManager.mode === 'rect-select'} + + {:else} + + {/if} + + {#snippet text()} +
+
+ + Grab: Click and drag to pan. Hold + {getModifierKey()} to box select. +
+
+ + Select Click and drag to box + select. Hold + {getModifierKey()} to pan. +
+
+ {/snippet} +
+
+ {/if} {#if download} { 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} {/if} + {#if !hideNotesToggle} + + {/if} {#if showDataflow} {/if} @@ -703,4 +1007,9 @@ :global(.svelte-flow__edgelabel-renderer) { @apply z-50; } + + :global(.svelte-flow__selection) { + display: none; + pointer-events: none; + } diff --git a/frontend/src/lib/components/graph/NodeContextMenu.svelte b/frontend/src/lib/components/graph/NodeContextMenu.svelte new file mode 100644 index 0000000000..034fc2d7da --- /dev/null +++ b/frontend/src/lib/components/graph/NodeContextMenu.svelte @@ -0,0 +1,47 @@ + + +{#if noteEditorContext?.noteEditor && selectedNodeIds.length > 1} + + {@render children()} + +{/if} diff --git a/frontend/src/lib/components/graph/NoteColorPicker.svelte b/frontend/src/lib/components/graph/NoteColorPicker.svelte new file mode 100644 index 0000000000..d17adb9317 --- /dev/null +++ b/frontend/src/lib/components/graph/NoteColorPicker.svelte @@ -0,0 +1,50 @@ + + + + {#snippet trigger()} + + {/each} +
+ {/snippet} + diff --git a/frontend/src/lib/components/graph/NoteTool.svelte b/frontend/src/lib/components/graph/NoteTool.svelte new file mode 100644 index 0000000000..ad41bd974a --- /dev/null +++ b/frontend/src/lib/components/graph/NoteTool.svelte @@ -0,0 +1,216 @@ + + + +
{ + // 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?.() + } + } + }} + > + + {#if previewNote} +
+
+ {/if} +
+
+ + diff --git a/frontend/src/lib/components/graph/PaneContextMenu.svelte b/frontend/src/lib/components/graph/PaneContextMenu.svelte new file mode 100644 index 0000000000..b6b6b49ac9 --- /dev/null +++ b/frontend/src/lib/components/graph/PaneContextMenu.svelte @@ -0,0 +1,115 @@ + + +{#if contextMenuVisible} + + + + + +{/if} diff --git a/frontend/src/lib/components/graph/SelectionBoundingBox.svelte b/frontend/src/lib/components/graph/SelectionBoundingBox.svelte new file mode 100644 index 0000000000..5faff92d28 --- /dev/null +++ b/frontend/src/lib/components/graph/SelectionBoundingBox.svelte @@ -0,0 +1,82 @@ + + +{#if bounds() && selectedNodes.length > 1} + {@const currentBounds = bounds()!} + +
+ + {#if noteEditorContext?.noteEditor} +
+ +
+ {/if} +
+
+{/if} diff --git a/frontend/src/lib/components/graph/SelectionTool.svelte b/frontend/src/lib/components/graph/SelectionTool.svelte new file mode 100644 index 0000000000..e7af55bc24 --- /dev/null +++ b/frontend/src/lib/components/graph/SelectionTool.svelte @@ -0,0 +1,43 @@ + + + +{#if store.selectionRect} + {@const bounds = store.selectionRect!} +
+
+{/if} diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index a6f0ebdd6a..6dded81b9c 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -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' }) diff --git a/frontend/src/lib/components/graph/graphContext.ts b/frontend/src/lib/components/graph/graphContext.ts new file mode 100644 index 0000000000..176fc5d3bf --- /dev/null +++ b/frontend/src/lib/components/graph/graphContext.ts @@ -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 + showAssets: Writable + 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(graphContextKey) +export const setGraphContext = (context: GraphContext) => setContext(graphContextKey, context) diff --git a/frontend/src/lib/components/graph/groupDetectionUtils.ts b/frontend/src/lib/components/graph/groupDetectionUtils.ts new file mode 100644 index 0000000000..e2c81c4d98 --- /dev/null +++ b/frontend/src/lib/components/graph/groupDetectionUtils.ts @@ -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() + for (const node of flowNodes) { + parents.set(node.id, node.parentIds || []) + } + + const groupSet = new Set(groupNodes) + const assignedComponent = new Map() + const components: Array> = [] + + 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 }[] = [ + { 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) +} diff --git a/frontend/src/lib/components/graph/noteColors.ts b/frontend/src/lib/components/graph/noteColors.ts new file mode 100644 index 0000000000..f9024adf7e --- /dev/null +++ b/frontend/src/lib/components/graph/noteColors.ts @@ -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.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.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 { + 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 diff --git a/frontend/src/lib/components/graph/noteEditor.svelte.ts b/frontend/src/lib/components/graph/noteEditor.svelte.ts new file mode 100644 index 0000000000..4d59cfa480 --- /dev/null +++ b/frontend/src/lib/components/graph/noteEditor.svelte.ts @@ -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 + private onNoteAdded?: () => void + + constructor(flowStore: StateStore, 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): 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 + } { + const notes = this.getNotes() + const groupNotes = notes.filter((note) => note.type === 'group') + const overlappingGroups: FlowNote[] = [] + const nodesInGroups = new Set() + + 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() + 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 = { + 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(CONTEXT_KEY, { noteEditor }) +} + +/** + * Get the NoteEditor context (used in components that need editing capabilities) + */ +export function getNoteEditorContext(): NoteEditorContext | undefined { + return getContext(CONTEXT_KEY) +} diff --git a/frontend/src/lib/components/graph/noteManager.svelte.ts b/frontend/src/lib/components/graph/noteManager.svelte.ts new file mode 100644 index 0000000000..6e605327a8 --- /dev/null +++ b/frontend/src/lib/components/graph/noteManager.svelte.ts @@ -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 = $state({ + notesCount: 0, + noteIds: [], + groupMemberships: [] + }) + #previousPropertySignature: ReturnType = $state([]) + + // Function to update nodes array with reactivity + #setNodes: (nodes: Node[]) => void + #getNodes: () => Node[] + + // Selection state + #selectedNoteId = $state(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() + } + } +} diff --git a/frontend/src/lib/components/graph/noteUtils.svelte.ts b/frontend/src/lib/components/graph/noteUtils.svelte.ts new file mode 100644 index 0000000000..d84817a693 --- /dev/null +++ b/frontend/src/lib/components/graph/noteUtils.svelte.ts @@ -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 +} + +export type AIToolSpacingInfo = { + toolNodes: (Node & NodeLayout)[] + toolEdges: any[] + newNodePositions: Record +} + +export interface GroupNoteBounds { + x: number + y: number + width: number + height: number +} + +let computeNoteNodesCache: + | [NodeDep[], FlowNote[], Record, 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 { + const zIndexMap: Record = {} + + // 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 = {} + 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, + 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 = {} // Y position -> spacing needed + + // Group notes that need spacing + const groupNotes = notes.filter((n) => n.type === 'group') + + const topMostNodesMap: Record = {} + + 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 = 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]) + } + } + } +} diff --git a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte index 568f16c0a0..3010a94325 100644 --- a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte +++ b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte @@ -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 - showAssets?: Writable - }>('FlowGraphContext') + const { useDataflow, showAssets } = getGraphContext() let { // id, diff --git a/frontend/src/lib/components/graph/renderers/edges/EmptyEdge.svelte b/frontend/src/lib/components/graph/renderers/edges/EmptyEdge.svelte index 47a099b7c8..0f622bc65c 100644 --- a/frontend/src/lib/components/graph/renderers/edges/EmptyEdge.svelte +++ b/frontend/src/lib/components/graph/renderers/edges/EmptyEdge.svelte @@ -1,8 +1,7 @@ @@ -322,7 +322,7 @@
+{/snippet} + +
{ + 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) + } + }} +> + + {#if hovering || selected} + {#if !editMode && isEditModeAvailable} +
+ {locked + ? 'Note is locked' + : isEditModeAvailable + ? 'Double click to edit' + : 'View only mode'} +
+ {:else if !locked && isEditModeAvailable} +
GH Markdown
+ {/if} + {/if} + + +
+ {#if editMode} + + + {:else} + +
containerHeight, + (v) => { + if (v > 0 && v !== containerHeight) { + data.onTextHeightChange?.(v) + } + containerHeight = v + } + } + > + {#if textForDisplay} +
+ +
+ {:else} +
+ Double click to edit me +
+ {/if} +
+ {/if} +
+ + + {#if !locked && isEditModeAvailable} + { + // 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} + + + {#if isEditModeAvailable} + {#if data.isGroupNote && currentNode?.position} + + +
+ {@render actionButtons()} +
+
+ {:else} + +
+ {@render actionButtons()} +
+ {/if} + {/if} +
+ + diff --git a/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte index 3917206edb..13ede87991 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte @@ -1,19 +1,17 @@ @@ -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)) diff --git a/frontend/src/lib/components/graph/renderers/nodes/SubflowBound.svelte b/frontend/src/lib/components/graph/renderers/nodes/SubflowBound.svelte index bb23d4566a..7e5438dda0 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/SubflowBound.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/SubflowBound.svelte @@ -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() @@ -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)) }} diff --git a/frontend/src/lib/components/graph/renderers/nodes/TriggersNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/TriggersNode.svelte index 7283739b5f..58f95d4b33 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/TriggersNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/TriggersNode.svelte @@ -1,9 +1,10 @@ @@ -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} { data?.eventHandlers?.select(e.detail) }} @@ -116,7 +122,7 @@ {:else} + + + + +

Input Components

diff --git a/frontend/src/routes/view_graph/+page.svelte b/frontend/src/routes/view_graph/+page.svelte index 18b49846a8..86bbba17cf 100644 --- a/frontend/src/routes/view_graph/+page.svelte +++ b/frontend/src/routes/view_graph/+page.svelte @@ -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 } @@ -15,6 +15,7 @@ {modules} {failureModule} {preprocessorModule} + {notes} />