@arronqzy/react-blueprint 1.0.7 → 1.0.9

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arronqzy/react-blueprint",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "",
5
5
  "main": "index.ts",
6
6
  "exports": {
@@ -12,10 +12,10 @@
12
12
  "dayjs": "^1.11.13",
13
13
  "react-dom": "^19",
14
14
  "@xyflow/react": "^12.9.3",
15
- "@arronqzy/blueprint-dsl": "1.0.6",
16
- "@arronqzy/react-rx-store": "1.1.4",
17
- "@arronqzy/i18n": "0.1.5",
18
- "@arronqzy/ui": "1.0.5"
15
+ "@arronqzy/blueprint-dsl": "1.0.8",
16
+ "@arronqzy/i18n": "0.1.7",
17
+ "@arronqzy/ui": "1.0.6",
18
+ "@arronqzy/react-rx-store": "1.1.5"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@types/react": "^19.0.1",
@@ -28,9 +28,9 @@
28
28
  "eslint": "^8.57.0",
29
29
  "typescript": "^5.5.4",
30
30
  "vite": "^5.1.4",
31
- "@arronqzy/tailwind": "1.0.2",
32
31
  "@arronqzy/eslint-config": "0.0.0",
33
- "@arronqzy/typescript-config": "1.0.0"
32
+ "@arronqzy/typescript-config": "1.0.0",
33
+ "@arronqzy/tailwind": "1.0.2"
34
34
  },
35
35
  "keywords": [],
36
36
  "author": "",
@@ -12,7 +12,7 @@ export type BlueprintContextMenuState =
12
12
  clientX: number;
13
13
  clientY: number;
14
14
  nodeId: string;
15
- role: "blueprint" | "logic" | "and" | "lifecycle" | "fetch" | "json" | "clock";
15
+ role: "blueprint" | "logic" | "and" | "lifecycle" | "event" | "fetch" | "json" | "clock";
16
16
  }
17
17
  | {
18
18
  kind: "edge";
@@ -6,12 +6,15 @@ import {
6
6
  SelectItem,
7
7
  SelectTrigger,
8
8
  SelectValue,
9
+ Checkbox,
9
10
  } from "@arronqzy/ui";
10
11
  import {
11
12
  LIFECYCLE_NODE_TYPE,
12
13
  PAGE_LIFECYCLE_PHASES,
14
+ VIEW_EVENT_TYPES,
13
15
  type ExecutionTraceEntry,
14
16
  type PageLifecyclePhase,
17
+ type ViewEventType,
15
18
  } from "@arronqzy/blueprint-dsl";
16
19
  import { useI18n } from "@arronqzy/i18n/react";
17
20
 
@@ -23,9 +26,12 @@ import { ViewElementMultiSelect } from "./components/ViewElementMultiSelect";
23
26
  import { ConfigFieldLabel, ConfigHintIcon, ConfigSectionTitle } from "./components/ConfigHintIcon";
24
27
  import {
25
28
  getLifecyclePhaseLabel,
29
+ getViewEventTypeLabel,
30
+ inspectEventNodeInputs,
26
31
  patchNodeConfigSource,
27
32
  pruneViewElementIds,
28
33
  resolveBlueprintConfigSource,
34
+ resolveNodeEventConfig,
29
35
  resolveViewElementIds,
30
36
  type BlueprintConfigSource,
31
37
  type BlueprintGraphEdge,
@@ -70,6 +76,7 @@ export type BlueprintNodeConfigSidebarProps = {
70
76
  | "jsonConfig"
71
77
  | "logicConfig"
72
78
  | "clockConfig"
79
+ | "eventConfig"
73
80
  >
74
81
  >
75
82
  ) => void;
@@ -83,6 +90,7 @@ const ROLE_LABEL_KEYS: Record<BlueprintNodeRole, string> = {
83
90
  json: "blueprint.config.roleJson",
84
91
  logic: "blueprint.config.roleLogic",
85
92
  clock: "blueprint.config.roleClock",
93
+ event: "blueprint.config.roleEvent",
86
94
  };
87
95
 
88
96
  export function BlueprintNodeConfigSidebar({
@@ -99,6 +107,7 @@ export function BlueprintNodeConfigSidebar({
99
107
  const { t } = useI18n();
100
108
  const configSource = resolveBlueprintConfigSource(node);
101
109
  const linkedViewElementIds = resolveViewElementIds(node);
110
+ const selectedEventTypes = resolveNodeEventConfig(node).eventTypes;
102
111
  const existingViewElementIdSet = useMemo(
103
112
  () => new Set(viewElementOptions.map((opt) => opt.id)),
104
113
  [viewElementOptions]
@@ -108,7 +117,7 @@ export function BlueprintNodeConfigSidebar({
108
117
  );
109
118
 
110
119
  useEffect(() => {
111
- if (configSource !== "view") return;
120
+ if (configSource !== "view" && configSource !== "event") return;
112
121
  // 视图画布尚未就绪时不清理,避免误删有效绑定导致绑定节点「执行了但不写 scope」
113
122
  if (viewElementOptions.length === 0) return;
114
123
  const linked = resolveViewElementIds(node);
@@ -118,7 +127,7 @@ export function BlueprintNodeConfigSidebar({
118
127
  onUpdateNode(node.id, {
119
128
  viewElementIds: pruned.length > 0 ? pruned : undefined,
120
129
  viewElementId: undefined,
121
- configSource: "view",
130
+ configSource,
122
131
  });
123
132
  }, [
124
133
  configSource,
@@ -131,6 +140,10 @@ export function BlueprintNodeConfigSidebar({
131
140
  ]);
132
141
 
133
142
  const roleLabel = t(ROLE_LABEL_KEYS[node.role] ?? "blueprint.config.roleLogic");
143
+ const eventInputs = useMemo(
144
+ () => inspectEventNodeInputs(node.id, graphNodes, graphEdges),
145
+ [graphEdges, graphNodes, node.id]
146
+ );
134
147
 
135
148
  return (
136
149
  <div className="flex h-full min-h-0 flex-col overflow-hidden bg-background text-foreground">
@@ -160,7 +173,7 @@ export function BlueprintNodeConfigSidebar({
160
173
  const nextSource = value as BlueprintConfigSource;
161
174
  onUpdateNode(node.id, {
162
175
  ...patchNodeConfigSource(node, nextSource),
163
- ...(nextSource === "view"
176
+ ...(nextSource === "view" || nextSource === "event"
164
177
  ? {
165
178
  viewElementIds: linkedViewElementIds,
166
179
  viewElementId: undefined,
@@ -183,16 +196,21 @@ export function BlueprintNodeConfigSidebar({
183
196
  <SelectItem value="fetch">{t("blueprint.config.configFetch")}</SelectItem>
184
197
  <SelectItem value="json">{t("blueprint.config.configJson")}</SelectItem>
185
198
  <SelectItem value="clock">{t("blueprint.config.configClock")}</SelectItem>
199
+ <SelectItem value="event">{t("blueprint.config.configEvent")}</SelectItem>
186
200
  <SelectItem value="view">{t("blueprint.config.configView")}</SelectItem>
187
201
  </SelectContent>
188
202
  </Select>
189
203
  </label>
190
204
 
191
- {configSource === "view" ? (
205
+ {configSource === "view" || configSource === "event" ? (
192
206
  <div className="block space-y-1">
193
207
  <ConfigFieldLabel
194
208
  label={t("blueprint.config.linkedViewNodes")}
195
- hint={t("blueprint.config.viewMultiHint")}
209
+ hint={
210
+ configSource === "event"
211
+ ? t("blueprint.config.eventViewHint")
212
+ : t("blueprint.config.viewMultiHint")
213
+ }
196
214
  />
197
215
  <ViewElementMultiSelect
198
216
  options={viewElementOptions}
@@ -202,7 +220,7 @@ export function BlueprintNodeConfigSidebar({
202
220
  onUpdateNode(node.id, {
203
221
  viewElementIds: next,
204
222
  viewElementId: undefined,
205
- configSource: "view",
223
+ configSource,
206
224
  })
207
225
  }
208
226
  />
@@ -270,6 +288,55 @@ export function BlueprintNodeConfigSidebar({
270
288
  </div>
271
289
  ) : null}
272
290
 
291
+ {configSource === "event" ? (
292
+ <div className="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5">
293
+ <div className="flex items-center gap-1.5">
294
+ <div className="font-medium text-foreground">
295
+ {t("blueprint.config.eventListen")}
296
+ </div>
297
+ <ConfigHintIcon label={t("blueprint.config.eventListen")}>
298
+ <p>{t("blueprint.config.eventNoInput")}</p>
299
+ <p>{t("blueprint.config.eventHint")}</p>
300
+ </ConfigHintIcon>
301
+ </div>
302
+ {eventInputs.nonLifecycleCount > 0 ? (
303
+ <p className="rounded border border-destructive/40 bg-destructive/10 px-2 py-1.5 text-[11px] leading-relaxed text-destructive">
304
+ {t("blueprint.config.eventInputNotLifecycle")}
305
+ </p>
306
+ ) : eventInputs.lifecycleCount === 0 ? (
307
+ <p className="rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[11px] leading-relaxed text-amber-700 dark:text-amber-300">
308
+ {t("blueprint.config.eventInputMissing")}
309
+ </p>
310
+ ) : null}
311
+ <div className="space-y-1.5">
312
+ {VIEW_EVENT_TYPES.map((eventType) => {
313
+ const checked = selectedEventTypes.includes(eventType);
314
+ return (
315
+ <label key={eventType} className="flex items-center gap-2">
316
+ <Checkbox
317
+ checked={checked}
318
+ className="h-3.5 w-3.5 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
319
+ onCheckedChange={(nextChecked: boolean | "indeterminate") => {
320
+ const nextTypes: ViewEventType[] =
321
+ nextChecked === true
322
+ ? [...new Set([...selectedEventTypes, eventType])]
323
+ : selectedEventTypes.filter((type) => type !== eventType);
324
+ onUpdateNode(node.id, {
325
+ eventConfig: {
326
+ eventTypes: nextTypes.length > 0 ? nextTypes : ["click"],
327
+ },
328
+ configSource: "event",
329
+ });
330
+ }}
331
+ />
332
+ <span>{getViewEventTypeLabel(t, eventType)}</span>
333
+ </label>
334
+ );
335
+ })}
336
+ </div>
337
+ </div>
338
+ ) : null}
339
+
273
340
  {configSource === "lifecycle" ? (
274
341
  <div className="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5">
275
342
  <div className="flex items-center gap-1.5">
@@ -6,6 +6,7 @@ import { ClockFlowNode } from "./nodes/ClockFlowNode";
6
6
  import { FetchFlowNode } from "./nodes/FetchFlowNode";
7
7
  import { JsonFlowNode } from "./nodes/JsonFlowNode";
8
8
  import { LifecycleFlowNode } from "./nodes/LifecycleFlowNode";
9
+ import { EventFlowNode } from "./nodes/EventFlowNode";
9
10
  import { LogicFlowNode } from "./nodes/LogicFlowNode";
10
11
 
11
12
  /** 稳定引用,避免 React Flow 因 nodeTypes 变化反复卸载节点 */
@@ -17,4 +18,5 @@ export const blueprintNodeTypes = {
17
18
  json: JsonFlowNode,
18
19
  logic: LogicFlowNode,
19
20
  lifecycle: LifecycleFlowNode,
21
+ event: EventFlowNode,
20
22
  } satisfies NodeTypes;
@@ -26,10 +26,13 @@ import {
26
26
  FETCH_REDIRECTS,
27
27
  FETCH_RESPONSE_TYPES,
28
28
  fetchConfigHasScopeTemplate,
29
+ getFetchBodyValidationError,
30
+ getFetchHeadersValidationError,
29
31
  latestTraceOutputsByNode,
30
32
  parseFetchHeadersJson,
31
33
  resolveFetchIncomingScope,
32
34
  resolveFetchRequestUrl,
35
+ resolveFetchScopeAutocompleteRoot,
33
36
  } from "@arronqzy/blueprint-dsl";
34
37
 
35
38
  import type { BlueprintGraphEdge, BlueprintGraphNode } from "../graph/document";
@@ -46,6 +49,7 @@ import { useI18n } from "@arronqzy/i18n/react";
46
49
 
47
50
  import { ConfigFieldLabel, ConfigHintIcon, ConfigSectionTitle } from "./ConfigHintIcon";
48
51
  import { FetchUrlAutocomplete } from "./FetchUrlAutocomplete";
52
+ import { ScopeTemplateAutocompleteHost } from "./ScopeTemplateAutocompleteHost";
49
53
 
50
54
  function SendIcon({ className }: { className?: string }) {
51
55
  return (
@@ -324,6 +328,18 @@ export function FetchNodeConfigPanel({
324
328
  return "";
325
329
  }
326
330
  }, [resolvedFetchConfig.headers, usesScopeTemplate]);
331
+ const resolvedBodyPreview = useMemo(() => {
332
+ if (!usesScopeTemplate) return "";
333
+ return resolvedFetchConfig.body?.trim() ?? "";
334
+ }, [resolvedFetchConfig.body, usesScopeTemplate]);
335
+ const headersJsonError = useMemo(
336
+ () => getFetchHeadersValidationError(fetchConfig, incomingScope),
337
+ [fetchConfig, incomingScope]
338
+ );
339
+ const bodyJsonError = useMemo(
340
+ () => getFetchBodyValidationError(fetchConfig.body, resolvedFetchConfig.body),
341
+ [fetchConfig.body, resolvedFetchConfig.body]
342
+ );
327
343
  const incomingHasPendingValue = useMemo(() => {
328
344
  if (!incomingScope || typeof incomingScope !== "object") return false;
329
345
  const entries =
@@ -332,6 +348,8 @@ export function FetchNodeConfigPanel({
332
348
  : Object.values(incomingScope as Record<string, { kind?: string }>);
333
349
  return entries.some((entry) => entry && entry.kind === "pending");
334
350
  }, [incomingScope]);
351
+ const autocompleteScope = resolveFetchScopeAutocompleteRoot(incomingScope);
352
+ const [formEl, setFormEl] = useState<HTMLDivElement | null>(null);
335
353
 
336
354
  const setUrlInputMode = useCallback(
337
355
  (mode: "swagger" | "manual") => {
@@ -381,6 +399,20 @@ export function FetchNodeConfigPanel({
381
399
  );
382
400
  return;
383
401
  }
402
+ const headersError = getFetchHeadersValidationError(fetchConfig, incomingScope);
403
+ if (headersError) {
404
+ setFetchValidationError(
405
+ t("blueprint.config.fetchHeadersInvalidJson", { error: headersError })
406
+ );
407
+ return;
408
+ }
409
+ const bodyError = getFetchBodyValidationError(fetchConfig.body, resolved.body);
410
+ if (bodyError) {
411
+ setFetchValidationError(
412
+ t("blueprint.config.fetchBodyInvalidJson", { error: bodyError })
413
+ );
414
+ return;
415
+ }
384
416
 
385
417
  setFetchValidationError(null);
386
418
  startFetchDebugTask({
@@ -394,7 +426,7 @@ export function FetchNodeConfigPanel({
394
426
  setFetchValidationError(null);
395
427
  }, [node.id]);
396
428
 
397
- const handleHeadersBlur = useCallback(
429
+ const handleHeadersChange = useCallback(
398
430
  (e: ChangeEvent<HTMLTextAreaElement>) => {
399
431
  const headersJson = e.target.value;
400
432
  const parsed = parseFetchHeadersJson(headersJson);
@@ -410,7 +442,14 @@ export function FetchNodeConfigPanel({
410
442
  );
411
443
 
412
444
  return (
413
- <div className="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5">
445
+ <div
446
+ ref={setFormEl}
447
+ className="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5"
448
+ >
449
+ <ScopeTemplateAutocompleteHost
450
+ scope={autocompleteScope}
451
+ container={formEl}
452
+ />
414
453
  <ConfigSectionTitle
415
454
  title={t("blueprint.config.fetchTitle")}
416
455
  hint={t("blueprint.config.fetchHint")}
@@ -444,6 +483,7 @@ export function FetchNodeConfigPanel({
444
483
  <Input
445
484
  value={fetchConfig.swaggerDocsUrl ?? ""}
446
485
  disabled={loadingSwagger}
486
+ data-scope-autocomplete="off"
447
487
  onChange={(e: ChangeEvent<HTMLInputElement>) =>
448
488
  onUpdateNode(
449
489
  node.id,
@@ -657,13 +697,20 @@ export function FetchNodeConfigPanel({
657
697
  hint={t("blueprint.config.fetchHeadersHint")}
658
698
  />
659
699
  <textarea
660
- defaultValue={headersDraft}
661
- key={`${node.id}-headers-${headersDraft}`}
662
- onBlur={handleHeadersBlur}
700
+ value={headersDraft}
701
+ onChange={handleHeadersChange}
663
702
  rows={4}
664
- className="w-full rounded-md border border-input bg-background px-2 py-1.5 font-mono text-[11px] leading-relaxed text-foreground shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-primary"
703
+ className={cn(
704
+ "w-full rounded-md border bg-background px-2 py-1.5 font-mono text-[11px] leading-relaxed text-foreground shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-primary",
705
+ headersJsonError ? "border-destructive" : "border-input"
706
+ )}
665
707
  placeholder='{"Authorization":"Bearer {scope?.value?.token}"}'
666
708
  />
709
+ {headersJsonError ? (
710
+ <p className="text-[11px] text-destructive">
711
+ {t("blueprint.config.fetchHeadersInvalidJson", { error: headersJsonError })}
712
+ </p>
713
+ ) : null}
667
714
  {usesScopeTemplate && resolvedHeadersPreview ? (
668
715
  <p className="whitespace-pre-wrap break-all font-mono text-[10px] text-muted-foreground">
669
716
  {t("blueprint.config.fetchScopeResolvedHeaders")}: {resolvedHeadersPreview}
@@ -672,16 +719,32 @@ export function FetchNodeConfigPanel({
672
719
  </label>
673
720
 
674
721
  <label className="block space-y-1">
675
- <span className="text-muted-foreground">{t("blueprint.config.requestBody")}</span>
722
+ <ConfigFieldLabel
723
+ label={t("blueprint.config.requestBody")}
724
+ hint={t("blueprint.config.fetchBodyHint")}
725
+ />
676
726
  <textarea
677
727
  value={fetchConfig.body ?? ""}
678
728
  onChange={(e) =>
679
729
  onUpdateNode(node.id, patchFetchConfig(node, { body: e.target.value }))
680
730
  }
681
731
  rows={4}
682
- className="w-full rounded-md border border-input bg-background px-2 py-1.5 font-mono text-[11px] leading-relaxed text-foreground shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-primary"
732
+ className={cn(
733
+ "w-full rounded-md border bg-background px-2 py-1.5 font-mono text-[11px] leading-relaxed text-foreground shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-primary",
734
+ bodyJsonError ? "border-destructive" : "border-input"
735
+ )}
683
736
  placeholder='{"id":"{scope?.value?.id}"}'
684
737
  />
738
+ {bodyJsonError ? (
739
+ <p className="text-[11px] text-destructive">
740
+ {t("blueprint.config.fetchBodyInvalidJson", { error: bodyJsonError })}
741
+ </p>
742
+ ) : null}
743
+ {usesScopeTemplate && resolvedBodyPreview ? (
744
+ <p className="whitespace-pre-wrap break-all font-mono text-[10px] text-muted-foreground">
745
+ {t("blueprint.config.fetchScopeResolvedBody")}: {resolvedBodyPreview}
746
+ </p>
747
+ ) : null}
685
748
  </label>
686
749
 
687
750
  <div className="grid grid-cols-2 gap-2">
@@ -103,6 +103,7 @@ export function FetchUrlAutocomplete({
103
103
  <input
104
104
  value={inputValue}
105
105
  readOnly={selectOnly && !open}
106
+ data-scope-autocomplete="off"
106
107
  placeholder={placeholder}
107
108
  onChange={(e) => {
108
109
  if (selectOnly) {
@@ -0,0 +1,245 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ type RefObject,
7
+ } from "react";
8
+ import { createPortal } from "react-dom";
9
+ import { cn } from "@arronqzy/ui";
10
+ import {
11
+ applyScopeAutocompleteSelection,
12
+ buildScopeExpression,
13
+ getScopeAutocompleteSuggestions,
14
+ parseScopeAutocomplete,
15
+ type ScopeAutocompleteState,
16
+ } from "@arronqzy/blueprint-dsl";
17
+
18
+ function isAutocompleteTarget(
19
+ target: EventTarget | null
20
+ ): target is HTMLInputElement | HTMLTextAreaElement {
21
+ if (target instanceof HTMLTextAreaElement) {
22
+ return target.dataset.scopeAutocomplete !== "off";
23
+ }
24
+ if (target instanceof HTMLInputElement) {
25
+ if (target.dataset.scopeAutocomplete === "off") return false;
26
+ const type = target.type;
27
+ return (
28
+ !type ||
29
+ type === "text" ||
30
+ type === "search" ||
31
+ type === "url" ||
32
+ type === "password"
33
+ );
34
+ }
35
+ return false;
36
+ }
37
+
38
+ function setNativeInputValue(
39
+ element: HTMLInputElement | HTMLTextAreaElement,
40
+ value: string
41
+ ) {
42
+ const proto =
43
+ element instanceof HTMLTextAreaElement
44
+ ? HTMLTextAreaElement.prototype
45
+ : HTMLInputElement.prototype;
46
+ const descriptor = Object.getOwnPropertyDescriptor(proto, "value");
47
+ descriptor?.set?.call(element, value);
48
+ element.dispatchEvent(new Event("input", { bubbles: true }));
49
+ }
50
+
51
+ type DropdownState = {
52
+ input: HTMLInputElement | HTMLTextAreaElement;
53
+ state: ScopeAutocompleteState;
54
+ suggestions: string[];
55
+ activeIndex: number;
56
+ rect: DOMRect;
57
+ };
58
+
59
+ export function ScopeTemplateAutocompleteHost({
60
+ scope,
61
+ container,
62
+ containerRef,
63
+ }: {
64
+ scope: unknown;
65
+ container?: HTMLElement | null;
66
+ containerRef?: RefObject<HTMLElement | null>;
67
+ }) {
68
+ const [dropdown, setDropdown] = useState<DropdownState | null>(null);
69
+ const dropdownRef = useRef<DropdownState | null>(null);
70
+ dropdownRef.current = dropdown;
71
+ const root = container ?? containerRef?.current ?? null;
72
+
73
+ const closeDropdown = useCallback(() => {
74
+ setDropdown(null);
75
+ }, []);
76
+
77
+ const refreshForInput = useCallback(
78
+ (input: HTMLInputElement | HTMLTextAreaElement) => {
79
+ if (scope === undefined) {
80
+ closeDropdown();
81
+ return;
82
+ }
83
+ const cursor = input.selectionStart ?? input.value.length;
84
+ const parsed = parseScopeAutocomplete(input.value, cursor, scope);
85
+ if (!parsed) {
86
+ closeDropdown();
87
+ return;
88
+ }
89
+ const suggestions = getScopeAutocompleteSuggestions(scope, parsed);
90
+ if (suggestions.length === 0) {
91
+ closeDropdown();
92
+ return;
93
+ }
94
+ setDropdown({
95
+ input,
96
+ state: parsed,
97
+ suggestions,
98
+ activeIndex: 0,
99
+ rect: input.getBoundingClientRect(),
100
+ });
101
+ },
102
+ [closeDropdown, scope]
103
+ );
104
+
105
+ const applySuggestion = useCallback(
106
+ (index: number) => {
107
+ const current = dropdownRef.current;
108
+ if (!current) return;
109
+ const selected = current.suggestions[index];
110
+ if (!selected) return;
111
+
112
+ const { value, cursor } = applyScopeAutocompleteSelection(
113
+ current.input.value,
114
+ current.state,
115
+ selected
116
+ );
117
+ setNativeInputValue(current.input, value);
118
+ current.input.focus();
119
+ current.input.setSelectionRange(cursor, cursor);
120
+ closeDropdown();
121
+ window.requestAnimationFrame(() => {
122
+ refreshForInput(current.input);
123
+ });
124
+ },
125
+ [closeDropdown, refreshForInput]
126
+ );
127
+
128
+ useEffect(() => {
129
+ if (!root || scope === undefined) return;
130
+
131
+ const onFocusIn = (event: FocusEvent) => {
132
+ if (!isAutocompleteTarget(event.target)) return;
133
+ refreshForInput(event.target);
134
+ };
135
+
136
+ const onInput = (event: Event) => {
137
+ if (!isAutocompleteTarget(event.target)) return;
138
+ refreshForInput(event.target);
139
+ };
140
+
141
+ const onKeyDown = (event: KeyboardEvent) => {
142
+ const current = dropdownRef.current;
143
+ if (!current || event.target !== current.input) return;
144
+
145
+ if (event.key === "ArrowDown") {
146
+ event.preventDefault();
147
+ setDropdown((prev) =>
148
+ prev
149
+ ? {
150
+ ...prev,
151
+ activeIndex: Math.min(
152
+ prev.activeIndex + 1,
153
+ prev.suggestions.length - 1
154
+ ),
155
+ }
156
+ : prev
157
+ );
158
+ return;
159
+ }
160
+ if (event.key === "ArrowUp") {
161
+ event.preventDefault();
162
+ setDropdown((prev) =>
163
+ prev
164
+ ? { ...prev, activeIndex: Math.max(prev.activeIndex - 1, 0) }
165
+ : prev
166
+ );
167
+ return;
168
+ }
169
+ if (event.key === "Enter" || event.key === "Tab") {
170
+ if (current.suggestions.length > 0) {
171
+ event.preventDefault();
172
+ applySuggestion(current.activeIndex);
173
+ }
174
+ return;
175
+ }
176
+ if (event.key === "Escape") {
177
+ event.preventDefault();
178
+ closeDropdown();
179
+ }
180
+ };
181
+
182
+ const onPointerDown = (event: MouseEvent) => {
183
+ const target = event.target as Node | null;
184
+ if (
185
+ dropdownRef.current?.input.contains(target ?? null) ||
186
+ (target instanceof Element && target.closest("[data-scope-ac-list]"))
187
+ ) {
188
+ return;
189
+ }
190
+ if (!root.contains(target)) {
191
+ closeDropdown();
192
+ }
193
+ };
194
+
195
+ root.addEventListener("focusin", onFocusIn);
196
+ root.addEventListener("input", onInput, true);
197
+ root.addEventListener("keydown", onKeyDown, true);
198
+ window.addEventListener("pointerdown", onPointerDown, true);
199
+ window.addEventListener("scroll", closeDropdown, true);
200
+ window.addEventListener("resize", closeDropdown);
201
+
202
+ return () => {
203
+ root.removeEventListener("focusin", onFocusIn);
204
+ root.removeEventListener("input", onInput, true);
205
+ root.removeEventListener("keydown", onKeyDown, true);
206
+ window.removeEventListener("pointerdown", onPointerDown, true);
207
+ window.removeEventListener("scroll", closeDropdown, true);
208
+ window.removeEventListener("resize", closeDropdown);
209
+ };
210
+ }, [applySuggestion, closeDropdown, refreshForInput, root, scope]);
211
+
212
+ if (!dropdown || scope === undefined) return null;
213
+
214
+ return createPortal(
215
+ <div
216
+ data-scope-ac-list
217
+ className="fixed z-[10120] max-h-44 min-w-[140px] overflow-auto rounded-md border border-border bg-popover py-1 text-popover-foreground shadow-md"
218
+ style={{
219
+ left: dropdown.rect.left,
220
+ top: dropdown.rect.bottom + 4,
221
+ width: Math.max(dropdown.rect.width, 160),
222
+ }}
223
+ >
224
+ {dropdown.suggestions.map((key, index) => {
225
+ const preview = buildScopeExpression(dropdown.state.path, key);
226
+ return (
227
+ <button
228
+ key={key}
229
+ type="button"
230
+ className={cn(
231
+ "flex w-full flex-col items-start gap-0.5 px-2 py-1.5 text-left text-[11px] hover:bg-accent",
232
+ index === dropdown.activeIndex && "bg-accent"
233
+ )}
234
+ onMouseDown={(event) => event.preventDefault()}
235
+ onClick={() => applySuggestion(index)}
236
+ >
237
+ <span className="font-medium text-foreground">{key}</span>
238
+ <span className="font-mono text-[10px] text-muted-foreground">{`{${preview}}`}</span>
239
+ </button>
240
+ );
241
+ })}
242
+ </div>,
243
+ document.body
244
+ );
245
+ }
@@ -5,6 +5,7 @@ import {
5
5
  filterInvalidBlueprintEdges,
6
6
  patchNodeConfigSource,
7
7
  resolveNodeClockConfig,
8
+ resolveNodeEventConfig,
8
9
  resolveNodeFetchConfig,
9
10
  resolveNodeJsonConfig,
10
11
  resolveNodeLogicConfig,
@@ -198,6 +199,7 @@ export class BlueprintGraph {
198
199
  | "jsonConfig"
199
200
  | "logicConfig"
200
201
  | "clockConfig"
202
+ | "eventConfig"
201
203
  >
202
204
  >
203
205
  ) {
@@ -251,6 +253,12 @@ export class BlueprintGraph {
251
253
  ...patch.clockConfig,
252
254
  };
253
255
  }
256
+ if (patch.eventConfig) {
257
+ nextNode.eventConfig = {
258
+ ...resolveNodeEventConfig(n),
259
+ ...patch.eventConfig,
260
+ };
261
+ }
254
262
  return {
255
263
  ...nextNode,
256
264
  nodeType: resolveRunnableNodeType(nextNode),