@d34dman/flowdrop 0.0.48 → 0.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -393,6 +393,13 @@
393
393
  * Uses enhanced API client with authProvider support when available.
394
394
  */
395
395
  async function saveWorkflow(): Promise<void> {
396
+ // Flush any pending form changes by blurring the active element.
397
+ // This ensures focusout handlers (like ConfigForm's handleFormBlur)
398
+ // sync local state to the global store before we read it.
399
+ if (document.activeElement instanceof HTMLElement) {
400
+ document.activeElement.blur();
401
+ }
402
+
396
403
  // Wait for any pending DOM updates before saving
397
404
  await tick();
398
405
 
@@ -149,7 +149,12 @@
149
149
  if (!node) return false;
150
150
  const staticSchema = schema ?? node.data.metadata?.configSchema;
151
151
  // Need to load if: (no static schema OR preferDynamicSchema is true) AND dynamic schema is configured
152
- return (!staticSchema || configEditOptions?.preferDynamicSchema === true) && useDynamicSchema && !fetchedDynamicSchema && !dynamicSchemaLoading;
152
+ return (
153
+ (!staticSchema || configEditOptions?.preferDynamicSchema === true) &&
154
+ useDynamicSchema &&
155
+ !fetchedDynamicSchema &&
156
+ !dynamicSchemaLoading
157
+ );
153
158
  });
154
159
 
155
160
  /**
@@ -399,7 +404,12 @@
399
404
  const fieldSchema = property as FieldSchema;
400
405
 
401
406
  // Process template fields to compute variable schema
402
- if (fieldSchema.format === 'template' && node && workflowNodes.length > 0 && workflowEdges.length > 0) {
407
+ if (
408
+ fieldSchema.format === 'template' &&
409
+ node &&
410
+ workflowNodes.length > 0 &&
411
+ workflowEdges.length > 0
412
+ ) {
403
413
  // Get the variables config (may be undefined or partially defined)
404
414
  const variablesConfig = fieldSchema.variables;
405
415
 
@@ -161,6 +161,7 @@
161
161
  memories: 'Memories',
162
162
  agents: 'Agents',
163
163
  ai: 'AI',
164
+ interrupts: 'Interrupts',
164
165
  bundles: 'Bundles'
165
166
  };
166
167
  return names[category] || category;
@@ -188,10 +188,10 @@
188
188
  undoHistoryLimit: {
189
189
  type: 'number',
190
190
  title: 'Undo History Limit',
191
- description: 'Maximum number of undo steps',
192
- minimum: 10,
191
+ description: 'Maximum number of undo steps (0 to disable)',
192
+ minimum: 0,
193
193
  maximum: 200,
194
- default: 50
194
+ default: 0
195
195
  },
196
196
  confirmDelete: {
197
197
  type: 'boolean',
@@ -61,7 +61,9 @@
61
61
 
62
62
  // Get AuthProvider and baseUrl from context via getter functions
63
63
  // This pattern ensures we always get the current value, even if props change after mount
64
- const getAuthProvider = getContext<(() => AuthProvider | undefined) | undefined>('flowdrop:getAuthProvider');
64
+ const getAuthProvider = getContext<(() => AuthProvider | undefined) | undefined>(
65
+ 'flowdrop:getAuthProvider'
66
+ );
65
67
  const getBaseUrl = getContext<(() => string) | undefined>('flowdrop:getBaseUrl');
66
68
 
67
69
  // Configuration with defaults
@@ -25,12 +25,12 @@
25
25
  import { EditorState } from '@codemirror/state';
26
26
  import { history, historyKeymap } from '@codemirror/commands';
27
27
  import { highlightSpecialChars, highlightActiveLine } from '@codemirror/view';
28
- import { syntaxHighlighting, defaultHighlightStyle, indentOnInput } from '@codemirror/language';
28
+ import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language';
29
29
  import { keymap } from '@codemirror/view';
30
30
  import { defaultKeymap, indentWithTab } from '@codemirror/commands';
31
31
  import { json, jsonParseLinter } from '@codemirror/lang-json';
32
32
  import { oneDark } from '@codemirror/theme-one-dark';
33
- import { linter, lintGutter } from '@codemirror/lint';
33
+ import { linter } from '@codemirror/lint';
34
34
 
35
35
  interface Props {
36
36
  /** Field identifier */
@@ -80,6 +80,9 @@
80
80
  /** Flag to prevent update loops */
81
81
  let isInternalUpdate = false;
82
82
 
83
+ /** Flag to skip $effect when change originated from the editor */
84
+ let isEditorUpdate = false;
85
+
83
86
  /**
84
87
  * Convert value to JSON string for editor display
85
88
  */
@@ -129,6 +132,7 @@
129
132
  const content = update.state.doc.toString();
130
133
  const result = validateAndParse(content);
131
134
 
135
+ isEditorUpdate = true;
132
136
  if (result.valid) {
133
137
  validationError = undefined;
134
138
  // Emit the parsed value (object) not the string
@@ -184,22 +188,17 @@
184
188
  // Editing features (skip when read-only)
185
189
  ...(disabled
186
190
  ? []
187
- : [
188
- history(),
189
- indentOnInput(),
190
- keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab])
191
- ]),
191
+ : [history(), keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab])]),
192
192
 
193
193
  // Read-only: prevent document changes and mark content as non-editable
194
194
  ...(disabled ? [EditorState.readOnly.of(true), EditorView.editable.of(false)] : []),
195
195
 
196
- // Syntax highlighting
197
- syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
196
+ // Syntax highlighting - use default for light mode, oneDark handles dark mode
197
+ ...(darkTheme ? [oneDark] : [syntaxHighlighting(defaultHighlightStyle, { fallback: true })]),
198
198
 
199
199
  // JSON-specific features
200
200
  json(),
201
- linter(jsonParseLinter()),
202
- lintGutter(),
201
+ linter(jsonParseLinter(), { delay: 1000 }),
203
202
 
204
203
  // Update listener (only fires on user edit when not disabled)
205
204
  EditorView.updateListener.of(handleUpdate),
@@ -224,10 +223,6 @@
224
223
  EditorView.lineWrapping
225
224
  ];
226
225
 
227
- if (darkTheme) {
228
- extensions.push(oneDark);
229
- }
230
-
231
226
  return extensions;
232
227
  }
233
228
 
@@ -276,6 +271,13 @@
276
271
  }
277
272
 
278
273
  const newContent = valueToString(value);
274
+
275
+ // Skip if the change originated from the editor itself
276
+ if (isEditorUpdate) {
277
+ isEditorUpdate = false;
278
+ return;
279
+ }
280
+
279
281
  const currentContent = editorView.state.doc.toString();
280
282
 
281
283
  // Only update if content actually changed and wasn't from internal edit
@@ -44,6 +44,7 @@
44
44
  import type { FieldSchema } from './types.js';
45
45
  import { getSchemaOptions } from './types.js';
46
46
  import type { WorkflowNode, WorkflowEdge, AuthProvider } from '../../types/index.js';
47
+ import { resolvedTheme } from '../../stores/settingsStore.js';
47
48
 
48
49
  interface Props {
49
50
  /** Unique key/id for the field */
@@ -345,7 +346,7 @@
345
346
  placeholder={schema.placeholder ?? '{}'}
346
347
  {required}
347
348
  height={(schema.height as string | undefined) ?? '200px'}
348
- darkTheme={(schema.darkTheme as boolean | undefined) ?? false}
349
+ darkTheme={(schema.darkTheme as boolean | undefined) ?? $resolvedTheme === 'dark'}
349
350
  autoFormat={(schema.autoFormat as boolean | undefined) ?? true}
350
351
  ariaDescribedBy={descriptionId}
351
352
  disabled={isReadOnly}
@@ -373,7 +374,7 @@
373
374
  'Enter your template here...\nUse {{ variable }} for dynamic values.'}
374
375
  {required}
375
376
  height={(schema.height as string | undefined) ?? '250px'}
376
- darkTheme={(schema.darkTheme as boolean | undefined) ?? false}
377
+ darkTheme={(schema.darkTheme as boolean | undefined) ?? $resolvedTheme === 'dark'}
377
378
  variables={schema.variables}
378
379
  placeholderExample={(schema.placeholderExample as string | undefined) ??
379
380
  'Hello {{ name }}, your order #{{ order_id }} is ready!'}
@@ -27,15 +27,14 @@
27
27
  highlightSpecialChars,
28
28
  highlightActiveLine,
29
29
  keymap,
30
+ tooltips,
30
31
  Decoration,
31
- type DecorationSet,
32
32
  ViewPlugin,
33
- type ViewUpdate,
34
33
  MatchDecorator
35
34
  } from '@codemirror/view';
36
35
  import { EditorState, Compartment } from '@codemirror/state';
37
36
  import { history, historyKeymap, defaultKeymap, indentWithTab } from '@codemirror/commands';
38
- import { syntaxHighlighting, defaultHighlightStyle, indentOnInput } from '@codemirror/language';
37
+ import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language';
39
38
  import { oneDark } from '@codemirror/theme-one-dark';
40
39
  import type {
41
40
  VariableSchema,
@@ -207,36 +206,37 @@
207
206
  const autocompleteCompartment = new Compartment();
208
207
 
209
208
  /**
210
- * Create a MatchDecorator for {{ variable }} patterns
211
- * This highlights the entire {{ variable }} expression
212
- * Supports:
213
- * - Simple variables: {{ name }}
214
- * - Dot notation: {{ user.name }}, {{ user.address.city }}
215
- * - Array access: {{ items[0] }}, {{ items[0].name }}
216
- * - Mixed: {{ orders[0].items[1].price }}
209
+ * Custom Twig syntax highlighter using MatchDecorator
210
+ * Highlights three Twig delimiter types with different styles:
211
+ * - {{ expression }} — variables/output (purple)
212
+ * - {% block %} — control structures (teal)
213
+ * - {# comment #} comments (gray/italic)
217
214
  */
218
- const variableMatcher = new MatchDecorator({
219
- // Match {{ variable_name }} patterns with dot notation and array indices
220
- regexp: /\{\{\s*[\w]+(?:\.[\w]+|\[\d+\]|\[\*\])*\s*\}\}/g,
221
- decoration: Decoration.mark({ class: 'cm-template-variable' })
215
+ const twigMatcher = new MatchDecorator({
216
+ regexp: /\{\{.*?\}\}|\{%.*?%\}|\{#.*?#\}/g,
217
+ decoration: (match) => {
218
+ const text = match[0];
219
+ if (text.startsWith('{{')) {
220
+ return Decoration.mark({ class: 'cm-twig-expression' });
221
+ } else if (text.startsWith('{%')) {
222
+ return Decoration.mark({ class: 'cm-twig-block' });
223
+ } else {
224
+ return Decoration.mark({ class: 'cm-twig-comment' });
225
+ }
226
+ }
222
227
  });
223
228
 
224
- /**
225
- * ViewPlugin that applies the variable highlighting decorations
226
- */
227
- const variableHighlighter = ViewPlugin.fromClass(
229
+ const twigHighlighter = ViewPlugin.fromClass(
228
230
  class {
229
- decorations: DecorationSet;
231
+ decorations;
230
232
  constructor(view: EditorView) {
231
- this.decorations = variableMatcher.createDeco(view);
233
+ this.decorations = twigMatcher.createDeco(view);
232
234
  }
233
- update(update: ViewUpdate) {
234
- this.decorations = variableMatcher.updateDeco(update, this.decorations);
235
+ update(update: import('@codemirror/view').ViewUpdate) {
236
+ this.decorations = twigMatcher.updateDeco(update, this.decorations);
235
237
  }
236
238
  },
237
- {
238
- decorations: (v) => v.decorations
239
- }
239
+ { decorations: (v) => v.decorations }
240
240
  );
241
241
 
242
242
  /**
@@ -258,6 +258,9 @@
258
258
  */
259
259
  function createExtensions() {
260
260
  const extensions = [
261
+ // Position tooltips using fixed strategy so they aren't clipped by container overflow
262
+ tooltips({ position: 'fixed' }),
263
+
261
264
  // Essential visual features
262
265
  lineNumbers(),
263
266
  highlightActiveLineGutter(),
@@ -268,20 +271,16 @@
268
271
  // Editing features (skip when read-only)
269
272
  ...(disabled
270
273
  ? []
271
- : [
272
- history(),
273
- indentOnInput(),
274
- keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab])
275
- ]),
274
+ : [history(), keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab])]),
276
275
 
277
276
  // Read-only: prevent document changes and mark content as non-editable
278
277
  ...(disabled ? [EditorState.readOnly.of(true), EditorView.editable.of(false)] : []),
279
278
 
280
- // Syntax highlighting
281
- syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
279
+ // Syntax highlighting - use default for light mode, oneDark handles dark mode
280
+ ...(darkTheme ? [] : [syntaxHighlighting(defaultHighlightStyle, { fallback: true })]),
282
281
 
283
- // Template-specific variable highlighter
284
- variableHighlighter,
282
+ // Twig syntax highlighting ({{ expressions }}, {% blocks %}, {# comments #})
283
+ twigHighlighter,
285
284
 
286
285
  // Update listener (only fires on user edit when not disabled)
287
286
  EditorView.updateListener.of(handleUpdate),
@@ -306,14 +305,27 @@
306
305
  '.cm-line': {
307
306
  padding: '0 0.5rem'
308
307
  },
309
- // Style for the highlighted {{ variable }} pattern
310
- '.cm-template-variable': {
308
+ // Twig expression: {{ variable }}
309
+ '.cm-twig-expression': {
311
310
  color: '#a855f7',
312
311
  backgroundColor: 'rgba(168, 85, 247, 0.1)',
313
312
  borderRadius: '3px',
314
313
  padding: '1px 2px',
315
314
  fontWeight: '500'
316
315
  },
316
+ // Twig block: {% for ... %}
317
+ '.cm-twig-block': {
318
+ color: '#14b8a6',
319
+ backgroundColor: 'rgba(20, 184, 166, 0.1)',
320
+ borderRadius: '3px',
321
+ padding: '1px 2px',
322
+ fontWeight: '500'
323
+ },
324
+ // Twig comment: {# ... #}
325
+ '.cm-twig-comment': {
326
+ color: '#6b7280',
327
+ fontStyle: 'italic'
328
+ },
317
329
  // Autocomplete dropdown styling
318
330
  '.cm-tooltip.cm-tooltip-autocomplete': {
319
331
  backgroundColor: 'var(--fd-background, #ffffff)',
@@ -355,20 +367,29 @@
355
367
  // Add autocomplete compartment (can be reconfigured dynamically)
356
368
  // When disabled or no schema, use empty array
357
369
  if (!disabled && effectiveVariableSchema) {
358
- extensions.push(autocompleteCompartment.of(createTemplateAutocomplete(effectiveVariableSchema)));
370
+ extensions.push(
371
+ autocompleteCompartment.of(createTemplateAutocomplete(effectiveVariableSchema))
372
+ );
359
373
  } else {
360
374
  extensions.push(autocompleteCompartment.of([]));
361
375
  }
362
376
 
363
377
  if (darkTheme) {
364
378
  extensions.push(oneDark);
365
- // Add dark theme override for variable highlighting and autocomplete
379
+ // Add dark theme overrides for Twig highlighting and autocomplete
366
380
  extensions.push(
367
381
  EditorView.theme({
368
- '.cm-template-variable': {
382
+ '.cm-twig-expression': {
369
383
  color: '#c084fc',
370
384
  backgroundColor: 'rgba(192, 132, 252, 0.15)'
371
385
  },
386
+ '.cm-twig-block': {
387
+ color: '#5eead4',
388
+ backgroundColor: 'rgba(94, 234, 212, 0.1)'
389
+ },
390
+ '.cm-twig-comment': {
391
+ color: '#6b7280'
392
+ },
372
393
  '.cm-tooltip.cm-tooltip-autocomplete': {
373
394
  backgroundColor: '#1e1e1e',
374
395
  border: '1px solid #3e4451',
@@ -471,9 +492,7 @@
471
492
 
472
493
  // When effectiveVariableSchema changes, reconfigure the autocomplete compartment
473
494
  // This happens after async API loading completes
474
- const newAutocomplete = !disabled && schema
475
- ? createTemplateAutocomplete(schema)
476
- : [];
495
+ const newAutocomplete = !disabled && schema ? createTemplateAutocomplete(schema) : [];
477
496
 
478
497
  editorView.dispatch({
479
498
  effects: [autocompleteCompartment.reconfigure(newAutocomplete)]
@@ -511,13 +530,7 @@
511
530
  fill="none"
512
531
  viewBox="0 0 24 24"
513
532
  >
514
- <circle
515
- class="opacity-25"
516
- cx="12"
517
- cy="12"
518
- r="10"
519
- stroke="currentColor"
520
- stroke-width="4"
533
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
521
534
  ></circle>
522
535
  <path
523
536
  class="opacity-75"
@@ -10,8 +10,8 @@
10
10
  *
11
11
  * @module components/form/templateAutocomplete
12
12
  */
13
- import type { Extension } from "@codemirror/state";
14
- import type { VariableSchema } from "../../types/index.js";
13
+ import type { Extension } from '@codemirror/state';
14
+ import type { VariableSchema } from '../../types/index.js';
15
15
  /**
16
16
  * Creates a CodeMirror extension for template variable autocomplete.
17
17
  *
@@ -10,20 +10,20 @@
10
10
  *
11
11
  * @module components/form/templateAutocomplete
12
12
  */
13
- import { autocompletion } from "@codemirror/autocomplete";
14
- import { getChildVariables, getArrayIndexSuggestions, isArrayVariable, hasChildren } from "../../services/variableService.js";
13
+ import { autocompletion } from '@codemirror/autocomplete';
14
+ import { getChildVariables, getArrayIndexSuggestions, isArrayVariable, hasChildren } from '../../services/variableService.js';
15
15
  /**
16
16
  * Icon type hints for different variable types in autocomplete dropdown.
17
17
  */
18
18
  const TYPE_ICONS = {
19
- string: "𝑆",
20
- number: "#",
21
- integer: "#",
22
- float: "#",
23
- boolean: "",
24
- array: "[]",
25
- object: "{}",
26
- mixed: ""
19
+ string: '𝑆',
20
+ number: '#',
21
+ integer: '#',
22
+ float: '#',
23
+ boolean: '',
24
+ array: '[]',
25
+ object: '{}',
26
+ mixed: ''
27
27
  };
28
28
  /**
29
29
  * Extracts the current variable path being typed inside {{ }}.
@@ -43,12 +43,12 @@ function extractVariablePath(text, pos) {
43
43
  let searchPos = pos - 1;
44
44
  while (searchPos >= 0) {
45
45
  // Check for opening {{
46
- if (text[searchPos] === "{" && searchPos > 0 && text[searchPos - 1] === "{") {
46
+ if (text[searchPos] === '{' && searchPos > 0 && text[searchPos - 1] === '{') {
47
47
  openBracePos = searchPos - 1;
48
48
  break;
49
49
  }
50
50
  // Check for closing }} - means we're outside an expression
51
- if (text[searchPos] === "}" && searchPos > 0 && text[searchPos - 1] === "}") {
51
+ if (text[searchPos] === '}' && searchPos > 0 && text[searchPos - 1] === '}') {
52
52
  return null;
53
53
  }
54
54
  searchPos--;
@@ -65,7 +65,8 @@ function extractVariablePath(text, pos) {
65
65
  const content = text.slice(contentStart, pos).trimStart();
66
66
  return {
67
67
  path: content,
68
- startPos: contentStart + (text.slice(contentStart, pos).length - text.slice(contentStart, pos).trimStart().length),
68
+ startPos: contentStart +
69
+ (text.slice(contentStart, pos).length - text.slice(contentStart, pos).trimStart().length),
69
70
  isInsideExpression: true
70
71
  };
71
72
  }
@@ -77,35 +78,35 @@ function extractVariablePath(text, pos) {
77
78
  */
78
79
  function getCompletionType(path) {
79
80
  // Empty or only whitespace - show top-level variables
80
- if (path.trim() === "") {
81
- return { type: "top-level" };
81
+ if (path.trim() === '') {
82
+ return { type: 'top-level' };
82
83
  }
83
84
  // Ends with [ - show array indices
84
- if (path.endsWith("[")) {
85
+ if (path.endsWith('[')) {
85
86
  const parentPath = path.slice(0, -1);
86
- return { type: "array-index", parentPath };
87
+ return { type: 'array-index', parentPath };
87
88
  }
88
89
  // Ends with . - show child properties
89
- if (path.endsWith(".")) {
90
+ if (path.endsWith('.')) {
90
91
  const parentPath = path.slice(0, -1);
91
- return { type: "property", parentPath };
92
+ return { type: 'property', parentPath };
92
93
  }
93
94
  // Otherwise, we're typing a variable name - show matching options
94
- const lastDotIndex = path.lastIndexOf(".");
95
- const lastBracketIndex = path.lastIndexOf("[");
95
+ const lastDotIndex = path.lastIndexOf('.');
96
+ const lastBracketIndex = path.lastIndexOf('[');
96
97
  const lastSeparator = Math.max(lastDotIndex, lastBracketIndex);
97
98
  if (lastSeparator === -1) {
98
99
  // Typing at top level
99
- return { type: "top-level" };
100
+ return { type: 'top-level' };
100
101
  }
101
102
  // Extract parent path based on separator
102
103
  if (lastDotIndex > lastBracketIndex) {
103
104
  // Last separator was a dot
104
- return { type: "property", parentPath: path.slice(0, lastDotIndex) };
105
+ return { type: 'property', parentPath: path.slice(0, lastDotIndex) };
105
106
  }
106
107
  else {
107
108
  // Last separator was a bracket
108
- return { type: "array-index", parentPath: path.slice(0, lastBracketIndex) };
109
+ return { type: 'array-index', parentPath: path.slice(0, lastBracketIndex) };
109
110
  }
110
111
  }
111
112
  /**
@@ -115,22 +116,22 @@ function getCompletionType(path) {
115
116
  * @param prefix - Prefix to add to the completion label
116
117
  * @returns A CodeMirror Completion object
117
118
  */
118
- function variableToCompletion(variable, prefix = "") {
119
+ function variableToCompletion(variable, prefix = '') {
119
120
  const icon = TYPE_ICONS[variable.type] ?? TYPE_ICONS.mixed;
120
121
  const hasChildProps = variable.properties && Object.keys(variable.properties).length > 0;
121
- const isArray = variable.type === "array";
122
+ const isArray = variable.type === 'array';
122
123
  // Add indicator if variable can be drilled into
123
- let suffix = "";
124
+ let suffix = '';
124
125
  if (hasChildProps)
125
- suffix = ".";
126
+ suffix = '.';
126
127
  else if (isArray)
127
- suffix = "[";
128
+ suffix = '[';
128
129
  return {
129
130
  label: `${prefix}${variable.name}`,
130
- displayLabel: `${icon} ${variable.label ?? variable.name}${suffix ? " " + suffix : ""}`,
131
+ displayLabel: `${icon} ${variable.label ?? variable.name}${suffix ? ' ' + suffix : ''}`,
131
132
  detail: variable.type,
132
133
  info: variable.description,
133
- type: "variable",
134
+ type: 'variable',
134
135
  boost: hasChildProps || isArray ? 1 : 0 // Boost drillable variables
135
136
  };
136
137
  }
@@ -149,7 +150,7 @@ function createTemplateCompletionSource(schema) {
149
150
  if (!pathInfo) {
150
151
  // Check if user just typed {{
151
152
  const beforeCursor = text.slice(Math.max(0, pos - 2), pos);
152
- if (beforeCursor === "{{") {
153
+ if (beforeCursor === '{{') {
153
154
  // Show top-level variables
154
155
  const options = Object.values(schema.variables).map((v) => variableToCompletion(v));
155
156
  return {
@@ -165,42 +166,42 @@ function createTemplateCompletionSource(schema) {
165
166
  let options = [];
166
167
  let from = pos;
167
168
  switch (completionType.type) {
168
- case "top-level": {
169
+ case 'top-level': {
169
170
  // Show all top-level variables
170
171
  const currentWord = path.trim();
171
172
  options = Object.values(schema.variables)
172
- .filter((v) => currentWord === "" || v.name.toLowerCase().startsWith(currentWord.toLowerCase()))
173
+ .filter((v) => currentWord === '' || v.name.toLowerCase().startsWith(currentWord.toLowerCase()))
173
174
  .map((v) => variableToCompletion(v));
174
175
  // Calculate from position for replacement
175
176
  from = startPos + (path.length - path.trimStart().length);
176
177
  break;
177
178
  }
178
- case "property": {
179
+ case 'property': {
179
180
  // Show child properties of the parent
180
181
  const children = getChildVariables(schema, completionType.parentPath);
181
- const currentWord = path.slice(path.lastIndexOf(".") + 1);
182
+ const currentWord = path.slice(path.lastIndexOf('.') + 1);
182
183
  options = children
183
- .filter((v) => currentWord === "" || v.name.toLowerCase().startsWith(currentWord.toLowerCase()))
184
+ .filter((v) => currentWord === '' || v.name.toLowerCase().startsWith(currentWord.toLowerCase()))
184
185
  .map((v) => variableToCompletion(v));
185
186
  // From should be right after the last dot
186
- from = startPos + path.lastIndexOf(".") + 1;
187
+ from = startPos + path.lastIndexOf('.') + 1;
187
188
  break;
188
189
  }
189
- case "array-index": {
190
+ case 'array-index': {
190
191
  // Check if the parent is actually an array
191
192
  if (isArrayVariable(schema, completionType.parentPath)) {
192
193
  const indices = getArrayIndexSuggestions(5);
193
- const currentIndex = path.slice(path.lastIndexOf("[") + 1);
194
+ const currentIndex = path.slice(path.lastIndexOf('[') + 1);
194
195
  options = indices
195
- .filter((idx) => currentIndex === "" || idx.startsWith(currentIndex))
196
+ .filter((idx) => currentIndex === '' || idx.startsWith(currentIndex))
196
197
  .map((idx) => ({
197
198
  label: idx,
198
- displayLabel: idx === "*]" ? "* (all items)" : `[${idx}`,
199
- detail: idx === "*]" ? "Iterate all items" : `Index ${idx.slice(0, -1)}`,
200
- type: "keyword"
199
+ displayLabel: idx === '*]' ? '* (all items)' : `[${idx}`,
200
+ detail: idx === '*]' ? 'Iterate all items' : `Index ${idx.slice(0, -1)}`,
201
+ type: 'keyword'
201
202
  }));
202
203
  // From should be right after the [
203
- from = startPos + path.lastIndexOf("[") + 1;
204
+ from = startPos + path.lastIndexOf('[') + 1;
204
205
  }
205
206
  break;
206
207
  }
@@ -234,13 +235,13 @@ export function createTemplateAutocomplete(schema) {
234
235
  override: [createTemplateCompletionSource(schema)],
235
236
  activateOnTyping: true,
236
237
  defaultKeymap: true,
237
- optionClass: () => "cm-template-autocomplete-option",
238
+ optionClass: () => 'cm-template-autocomplete-option',
238
239
  icons: false, // We use our own icons in displayLabel
239
240
  addToOptions: [
240
241
  {
241
242
  render: (completion) => {
242
- const el = document.createElement("span");
243
- el.className = "cm-template-autocomplete-info";
243
+ const el = document.createElement('span');
244
+ el.className = 'cm-template-autocomplete-info';
244
245
  if (completion.info) {
245
246
  el.textContent = String(completion.info);
246
247
  }
@@ -230,7 +230,7 @@
230
230
  .flowdrop-tool-node {
231
231
  position: relative;
232
232
  background-color: var(--fd-card);
233
- border: 1.5px solid var(--fd-node-border);
233
+ border: 1.5px solid var(--fd-tool-node-color);
234
234
  border-radius: var(--fd-radius-xl);
235
235
  width: var(--fd-node-default-width);
236
236
  min-height: var(--fd-node-tool-min-height);
@@ -246,7 +246,7 @@
246
246
 
247
247
  .flowdrop-tool-node:hover {
248
248
  box-shadow: var(--fd-shadow-lg);
249
- border-color: var(--fd-node-border-hover);
249
+ border-color: var(--fd-tool-node-color);
250
250
  }
251
251
 
252
252
  .flowdrop-tool-node--selected {
@@ -282,16 +282,14 @@
282
282
  /* Light mode: mix tool color with white (95%) for subtle tint */
283
283
  background-color: color-mix(in srgb, var(--fd-tool-node-color) 5%, white);
284
284
  border-radius: var(--fd-radius-xl);
285
- /* Light mode: mix tool color with white (40%) for border */
286
- border: 1px solid color-mix(in srgb, var(--fd-tool-node-color) 40%, white);
285
+ border: none;
287
286
  }
288
287
 
289
288
  /* Dark mode header styles */
290
289
  :global([data-theme='dark']) .flowdrop-tool-node__header {
291
290
  /* Dark mode: mix tool color with dark background (15%) for subtle tint */
292
291
  background-color: color-mix(in srgb, var(--fd-tool-node-color) 15%, #1a1a1e);
293
- /* Dark mode: mix tool color with dark background (35%) for border */
294
- border-color: color-mix(in srgb, var(--fd-tool-node-color) 35%, #1a1a1e);
292
+ border: none;
295
293
  }
296
294
 
297
295
  .flowdrop-tool-node__header-content {
@@ -62,6 +62,12 @@ async function ensureApiConfiguration() {
62
62
  * Uses the current workflow from the global store
63
63
  */
64
64
  export async function globalSaveWorkflow() {
65
+ // Flush any pending form changes by blurring the active element.
66
+ // This ensures focusout handlers (like ConfigForm's handleFormBlur)
67
+ // sync local state to the global store before we read it.
68
+ if (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement) {
69
+ document.activeElement.blur();
70
+ }
65
71
  let loadingToast;
66
72
  try {
67
73
  // Show loading toast
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * @module services/variableService
7
7
  */
8
- import type { WorkflowNode, WorkflowEdge, VariableSchema, TemplateVariable, TemplateVariablesConfig, AuthProvider } from "../types/index.js";
8
+ import type { WorkflowNode, WorkflowEdge, VariableSchema, TemplateVariable, TemplateVariablesConfig, AuthProvider } from '../types/index.js';
9
9
  /**
10
10
  * Options for deriving available variables.
11
11
  */
@@ -13,22 +13,22 @@
13
13
  */
14
14
  function toTemplateVariableType(schemaType) {
15
15
  switch (schemaType) {
16
- case "string":
17
- return "string";
18
- case "number":
19
- return "number";
20
- case "integer":
21
- return "integer";
22
- case "boolean":
23
- return "boolean";
24
- case "array":
25
- return "array";
26
- case "object":
27
- return "object";
28
- case "float":
29
- return "float";
16
+ case 'string':
17
+ return 'string';
18
+ case 'number':
19
+ return 'number';
20
+ case 'integer':
21
+ return 'integer';
22
+ case 'boolean':
23
+ return 'boolean';
24
+ case 'array':
25
+ return 'array';
26
+ case 'object':
27
+ return 'object';
28
+ case 'float':
29
+ return 'float';
30
30
  default:
31
- return "mixed";
31
+ return 'mixed';
32
32
  }
33
33
  }
34
34
  /**
@@ -51,15 +51,15 @@ function propertyToTemplateVariable(name, property, sourcePort, sourceNode) {
51
51
  sourceNode
52
52
  };
53
53
  // Handle nested object properties
54
- if (property.type === "object" && property.properties) {
54
+ if (property.type === 'object' && property.properties) {
55
55
  variable.properties = {};
56
56
  for (const [propName, propValue] of Object.entries(property.properties)) {
57
57
  variable.properties[propName] = propertyToTemplateVariable(propName, propValue, sourcePort, sourceNode);
58
58
  }
59
59
  }
60
60
  // Handle array items
61
- if (property.type === "array" && property.items) {
62
- variable.items = propertyToTemplateVariable("item", property.items, sourcePort, sourceNode);
61
+ if (property.type === 'array' && property.items) {
62
+ variable.items = propertyToTemplateVariable('item', property.items, sourcePort, sourceNode);
63
63
  }
64
64
  return variable;
65
65
  }
@@ -78,7 +78,7 @@ function portToTemplateVariable(port, sourceNode) {
78
78
  name: port.id,
79
79
  label: port.name,
80
80
  description: port.description,
81
- type: "object",
81
+ type: 'object',
82
82
  sourcePort: port.id,
83
83
  sourceNode,
84
84
  properties: {}
@@ -186,12 +186,12 @@ export function getAvailableVariables(node, nodes, edges, options) {
186
186
  for (const connection of connections) {
187
187
  const { sourceNode, sourcePort, targetPort } = connection;
188
188
  // Skip trigger ports - they don't carry data
189
- if (sourcePort?.dataType === "trigger")
189
+ if (sourcePort?.dataType === 'trigger')
190
190
  continue;
191
- if (targetPort?.dataType === "trigger")
191
+ if (targetPort?.dataType === 'trigger')
192
192
  continue;
193
193
  // Get the target port ID for filtering
194
- const targetPortId = targetPort?.id ?? sourcePort?.id ?? "data";
194
+ const targetPortId = targetPort?.id ?? sourcePort?.id ?? 'data';
195
195
  // Filter by target port IDs if specified
196
196
  if (targetPortIds !== undefined) {
197
197
  if (!targetPortIds.includes(targetPortId))
@@ -212,7 +212,7 @@ export function getAvailableVariables(node, nodes, edges, options) {
212
212
  }
213
213
  else {
214
214
  // No schema or includePortName is true - use port name as the variable
215
- const variableName = includePortName ? targetPortId : (targetPortId);
215
+ const variableName = includePortName ? targetPortId : targetPortId;
216
216
  // Skip if we already have a variable with this name
217
217
  if (variables[variableName])
218
218
  continue;
@@ -244,7 +244,7 @@ export function getAvailableVariables(node, nodes, edges, options) {
244
244
  * ```
245
245
  */
246
246
  export function getChildVariables(schema, path) {
247
- const parts = path.split(".");
247
+ const parts = path.split('.');
248
248
  let current;
249
249
  // Navigate to the target variable
250
250
  for (let i = 0; i < parts.length; i++) {
@@ -295,7 +295,7 @@ export function getArrayIndexSuggestions(maxIndex = 2) {
295
295
  suggestions.push(`${i}]`);
296
296
  }
297
297
  // Add wildcard for "all items"
298
- suggestions.push("*]");
298
+ suggestions.push('*]');
299
299
  return suggestions;
300
300
  }
301
301
  /**
@@ -306,7 +306,7 @@ export function getArrayIndexSuggestions(maxIndex = 2) {
306
306
  * @returns True if the variable is an array type
307
307
  */
308
308
  export function isArrayVariable(schema, path) {
309
- const parts = path.split(".");
309
+ const parts = path.split('.');
310
310
  let current;
311
311
  for (let i = 0; i < parts.length; i++) {
312
312
  const part = parts[i];
@@ -335,7 +335,7 @@ export function isArrayVariable(schema, path) {
335
335
  return false;
336
336
  }
337
337
  }
338
- return current?.type === "array";
338
+ return current?.type === 'array';
339
339
  }
340
340
  /**
341
341
  * Checks if a variable at the given path has child properties.
@@ -410,7 +410,7 @@ export async function getVariableSchema(node, nodes, edges, config, workflowId,
410
410
  if (config.api) {
411
411
  try {
412
412
  // Import API variable service dynamically to avoid circular dependencies
413
- const { fetchVariableSchema } = await import("./apiVariableService.js");
413
+ const { fetchVariableSchema } = await import('./apiVariableService.js');
414
414
  const apiResult = await fetchVariableSchema(workflowId, node.id, config.api, authProvider);
415
415
  if (apiResult.success && apiResult.schema) {
416
416
  resultSchema = apiResult.schema;
@@ -430,13 +430,13 @@ export async function getVariableSchema(node, nodes, edges, config, workflowId,
430
430
  }
431
431
  else if (!config.api.fallbackOnError) {
432
432
  // API failed and fallback is disabled - return empty schema
433
- console.error("Failed to fetch variables from API:", apiResult.error);
433
+ console.error('Failed to fetch variables from API:', apiResult.error);
434
434
  return { variables: {} };
435
435
  }
436
436
  // If fallback is enabled (default), continue to schema-based mode below
437
437
  }
438
438
  catch (error) {
439
- console.error("Error fetching variables from API:", error);
439
+ console.error('Error fetching variables from API:', error);
440
440
  // If fallback is disabled, return empty schema
441
441
  if (config.api.fallbackOnError === false) {
442
442
  return { variables: {} };
@@ -10,6 +10,7 @@ import type { Workflow, NodeMetadata, PortConfig } from './types/index.js';
10
10
  import type { EndpointConfig } from './config/endpoints.js';
11
11
  import type { AuthProvider } from './types/auth.js';
12
12
  import type { FlowDropEventHandlers, FlowDropFeatures } from './types/events.js';
13
+ import type { PartialSettings } from './types/settings.js';
13
14
  declare global {
14
15
  interface Window {
15
16
  flowdropSave?: () => Promise<void>;
@@ -66,6 +67,8 @@ export interface FlowDropMountOptions {
66
67
  eventHandlers?: FlowDropEventHandlers;
67
68
  /** Feature configuration */
68
69
  features?: FlowDropFeatures;
70
+ /** Initial settings overrides (theme, behavior, editor, ui, api) */
71
+ settings?: PartialSettings;
69
72
  /** Custom storage key for localStorage drafts */
70
73
  draftStorageKey?: string;
71
74
  }
@@ -15,6 +15,7 @@ import { fetchPortConfig } from './services/portConfigApi.js';
15
15
  import { isDirty, markAsSaved, getWorkflow as getWorkflowFromStore, setOnDirtyStateChange, setOnWorkflowChange } from './stores/workflowStore.js';
16
16
  import { DraftAutoSaveManager, getDraftStorageKey } from './services/draftStorage.js';
17
17
  import { mergeFeatures } from './types/events.js';
18
+ import { initializeSettings } from './stores/settingsStore.js';
18
19
  /**
19
20
  * Mount the full FlowDrop App with navbar, sidebars, and workflow editor
20
21
  *
@@ -40,9 +41,13 @@ import { mergeFeatures } from './types/events.js';
40
41
  * ```
41
42
  */
42
43
  export async function mountFlowDropApp(container, options = {}) {
43
- const { workflow, nodes, endpointConfig, portConfig, height = '100vh', width = '100%', showNavbar = false, disableSidebar, lockWorkflow, readOnly, nodeStatuses, pipelineId, navbarTitle, navbarActions, showSettings, authProvider, eventHandlers, features: userFeatures, draftStorageKey: customDraftKey } = options;
44
+ const { workflow, nodes, endpointConfig, portConfig, height = '100vh', width = '100%', showNavbar = false, disableSidebar, lockWorkflow, readOnly, nodeStatuses, pipelineId, navbarTitle, navbarActions, showSettings, authProvider, eventHandlers, features: userFeatures, settings: initialSettings, draftStorageKey: customDraftKey } = options;
44
45
  // Merge features with defaults
45
46
  const features = mergeFeatures(userFeatures);
47
+ // Apply initial settings overrides and initialize theme
48
+ await initializeSettings({
49
+ defaults: initialSettings
50
+ });
46
51
  // Create endpoint configuration
47
52
  let config;
48
53
  if (endpointConfig) {
@@ -8,7 +8,7 @@ import type { EndpointConfig } from '../config/endpoints.js';
8
8
  * Node category types for organizing nodes in the sidebar
9
9
  * Based on actual API response categories
10
10
  */
11
- export type NodeCategory = 'triggers' | 'inputs' | 'outputs' | 'prompts' | 'models' | 'processing' | 'logic' | 'data' | 'tools' | 'helpers' | 'vector stores' | 'embeddings' | 'memories' | 'agents' | 'ai' | 'bundles';
11
+ export type NodeCategory = 'triggers' | 'inputs' | 'outputs' | 'prompts' | 'models' | 'processing' | 'logic' | 'data' | 'tools' | 'helpers' | 'vector stores' | 'embeddings' | 'memories' | 'agents' | 'ai' | 'interrupts' | 'bundles';
12
12
  /**
13
13
  * Port data type configuration
14
14
  */
@@ -37,7 +37,7 @@ export const SETTINGS_CATEGORY_ICONS = {
37
37
  * Default theme settings
38
38
  */
39
39
  export const DEFAULT_THEME_SETTINGS = {
40
- preference: 'auto'
40
+ preference: 'light'
41
41
  };
42
42
  /**
43
43
  * Default editor settings
@@ -64,8 +64,8 @@ export const DEFAULT_UI_SETTINGS = {
64
64
  export const DEFAULT_BEHAVIOR_SETTINGS = {
65
65
  autoSave: false,
66
66
  autoSaveInterval: 30000,
67
- undoHistoryLimit: 50,
68
- confirmDelete: true
67
+ undoHistoryLimit: 0,
68
+ confirmDelete: false
69
69
  };
70
70
  /**
71
71
  * Default API settings
@@ -74,7 +74,7 @@ export const DEFAULT_API_SETTINGS = {
74
74
  timeout: 30000,
75
75
  retryEnabled: true,
76
76
  retryAttempts: 3,
77
- cacheEnabled: true
77
+ cacheEnabled: false
78
78
  };
79
79
  /**
80
80
  * Complete default settings object
@@ -24,6 +24,7 @@ export const CATEGORY_COLOR_TOKENS = {
24
24
  memories: 'var(--fd-node-blue)',
25
25
  agents: 'var(--fd-node-teal)',
26
26
  ai: 'var(--fd-node-purple)',
27
+ interrupts: 'var(--fd-node-red)',
27
28
  bundles: 'var(--fd-node-slate)'
28
29
  };
29
30
  /**
@@ -95,6 +95,7 @@ export const CATEGORY_ICONS = {
95
95
  memories: 'mdi:brain',
96
96
  agents: 'mdi:account-cog',
97
97
  ai: 'mdi:shimmer',
98
+ interrupts: 'mdi:hand-back-left',
98
99
  bundles: 'mdi:package-variant'
99
100
  };
100
101
  /**
@@ -194,9 +194,9 @@ export function createNodeTypeConfigProperty(metadata, defaultType) {
194
194
  const oneOf = getNodeTypeOneOfOptions(metadata);
195
195
  const primaryType = defaultType ?? getPrimaryNodeType(metadata);
196
196
  return {
197
- type: "string",
198
- title: "Node Type",
199
- description: "Choose the visual representation for this node",
197
+ type: 'string',
198
+ title: 'Node Type',
199
+ description: 'Choose the visual representation for this node',
200
200
  default: primaryType,
201
201
  oneOf
202
202
  };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@d34dman/flowdrop",
3
3
  "license": "MIT",
4
4
  "private": false,
5
- "version": "0.0.48",
5
+ "version": "0.0.49",
6
6
  "scripts": {
7
7
  "dev": "vite dev",
8
8
  "build": "vite build && npm run prepack",