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