@revenexx/studio-integrations 0.1.4 → 0.2.0

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.
Files changed (179) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/runtime/components/Integrations/CredentialCreateDialog.d.vue.ts +13 -0
  3. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +279 -0
  4. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue.d.ts +13 -0
  5. package/dist/runtime/components/Integrations/CredentialDetailDialog.d.vue.ts +20 -0
  6. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +342 -0
  7. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue.d.ts +20 -0
  8. package/dist/runtime/components/Integrations/CredentialLogo.d.vue.ts +14 -0
  9. package/dist/runtime/components/Integrations/CredentialLogo.vue +37 -0
  10. package/dist/runtime/components/Integrations/CredentialLogo.vue.d.ts +14 -0
  11. package/dist/runtime/components/Integrations/CredentialRevenexxSource.d.vue.ts +12 -0
  12. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +364 -0
  13. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue.d.ts +12 -0
  14. package/dist/runtime/components/Integrations/CredentialTypePicker.d.vue.ts +15 -0
  15. package/dist/runtime/components/Integrations/CredentialTypePicker.vue +71 -0
  16. package/dist/runtime/components/Integrations/CredentialTypePicker.vue.d.ts +15 -0
  17. package/dist/runtime/components/Integrations/DeadLettersPanel.d.vue.ts +5 -0
  18. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +151 -0
  19. package/dist/runtime/components/Integrations/DeadLettersPanel.vue.d.ts +5 -0
  20. package/dist/runtime/components/Integrations/EditorTip.d.vue.ts +17 -0
  21. package/dist/runtime/components/Integrations/EditorTip.vue +46 -0
  22. package/dist/runtime/components/Integrations/EditorTip.vue.d.ts +17 -0
  23. package/dist/runtime/components/Integrations/EventTypeCombobox.d.vue.ts +9 -0
  24. package/dist/runtime/components/Integrations/EventTypeCombobox.vue +64 -0
  25. package/dist/runtime/components/Integrations/EventTypeCombobox.vue.d.ts +9 -0
  26. package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +19 -0
  27. package/dist/runtime/components/Integrations/ExpressionInput.vue +236 -0
  28. package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +19 -0
  29. package/dist/runtime/components/Integrations/GroupFrameView.d.vue.ts +28 -0
  30. package/dist/runtime/components/Integrations/GroupFrameView.vue +222 -0
  31. package/dist/runtime/components/Integrations/GroupFrameView.vue.d.ts +28 -0
  32. package/dist/runtime/components/Integrations/NodeConfigDynamicField.d.vue.ts +14 -0
  33. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +184 -0
  34. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue.d.ts +14 -0
  35. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.d.vue.ts +16 -0
  36. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +102 -0
  37. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue.d.ts +16 -0
  38. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +23 -0
  39. package/dist/runtime/components/Integrations/NodeConfigField.vue +587 -0
  40. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +23 -0
  41. package/dist/runtime/components/Integrations/NodeDynamicOutputsResolver.d.vue.ts +14 -0
  42. package/dist/runtime/components/Integrations/NodeDynamicOutputsResolver.vue +80 -0
  43. package/dist/runtime/components/Integrations/NodeDynamicOutputsResolver.vue.d.ts +14 -0
  44. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +22 -3
  45. package/dist/runtime/components/Integrations/NodeInspector.vue +1074 -294
  46. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +22 -3
  47. package/dist/runtime/components/Integrations/NodeLogo.d.vue.ts +9 -0
  48. package/dist/runtime/components/Integrations/NodeLogo.vue +38 -0
  49. package/dist/runtime/components/Integrations/NodeLogo.vue.d.ts +9 -0
  50. package/dist/runtime/components/Integrations/NodePalette.d.vue.ts +22 -1
  51. package/dist/runtime/components/Integrations/NodePalette.vue +411 -90
  52. package/dist/runtime/components/Integrations/NodePalette.vue.d.ts +22 -1
  53. package/dist/runtime/components/Integrations/QueryBuilder.d.vue.ts +9 -0
  54. package/dist/runtime/components/Integrations/QueryBuilder.vue +239 -0
  55. package/dist/runtime/components/Integrations/QueryBuilder.vue.d.ts +9 -0
  56. package/dist/runtime/components/Integrations/{RunDetailDialog.d.vue.ts → RunActionsMenu.d.vue.ts} +3 -4
  57. package/dist/runtime/components/Integrations/RunActionsMenu.vue +86 -0
  58. package/dist/runtime/components/Integrations/{RunDetailDialog.vue.d.ts → RunActionsMenu.vue.d.ts} +3 -4
  59. package/dist/runtime/components/Integrations/RunDetailPanel.d.vue.ts +14 -0
  60. package/dist/runtime/components/Integrations/RunDetailPanel.vue +777 -0
  61. package/dist/runtime/components/Integrations/RunDetailPanel.vue.d.ts +14 -0
  62. package/dist/runtime/components/Integrations/RunPayloadInput.d.vue.ts +13 -0
  63. package/dist/runtime/components/Integrations/RunPayloadInput.vue +175 -0
  64. package/dist/runtime/components/Integrations/RunPayloadInput.vue.d.ts +13 -0
  65. package/dist/runtime/components/Integrations/RunResponseModal.d.vue.ts +27 -0
  66. package/dist/runtime/components/Integrations/RunResponseModal.vue +84 -0
  67. package/dist/runtime/components/Integrations/RunResponseModal.vue.d.ts +27 -0
  68. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +21 -0
  69. package/dist/runtime/components/Integrations/RunsTable.vue +114 -0
  70. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +21 -0
  71. package/dist/runtime/components/Integrations/SftpAccountImportDialog.d.vue.ts +11 -0
  72. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +91 -0
  73. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue.d.ts +11 -0
  74. package/dist/runtime/components/Integrations/StickyNoteView.d.vue.ts +28 -0
  75. package/dist/runtime/components/Integrations/StickyNoteView.vue +208 -0
  76. package/dist/runtime/components/Integrations/StickyNoteView.vue.d.ts +28 -0
  77. package/dist/runtime/components/Integrations/TemplateFlowPreview.d.vue.ts +29 -0
  78. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +201 -0
  79. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue.d.ts +29 -0
  80. package/dist/runtime/components/Integrations/WorkflowContextMenu.d.vue.ts +32 -0
  81. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +138 -0
  82. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue.d.ts +32 -0
  83. package/dist/runtime/components/Integrations/WorkflowCreateDialog.d.vue.ts +9 -0
  84. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +322 -0
  85. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue.d.ts +9 -0
  86. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.d.vue.ts +18 -0
  87. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +63 -0
  88. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue.d.ts +18 -0
  89. package/dist/runtime/components/Integrations/WorkflowEdgeView.d.vue.ts +12 -1
  90. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +68 -6
  91. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue.d.ts +12 -1
  92. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +8 -1
  93. package/dist/runtime/components/Integrations/WorkflowEditor.vue +1424 -144
  94. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +8 -1
  95. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.d.vue.ts +12 -0
  96. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +417 -0
  97. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue.d.ts +12 -0
  98. package/dist/runtime/components/Integrations/WorkflowIconPicker.d.vue.ts +10 -0
  99. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +89 -0
  100. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue.d.ts +10 -0
  101. package/dist/runtime/components/Integrations/WorkflowIconView.d.vue.ts +10 -0
  102. package/dist/runtime/components/Integrations/WorkflowIconView.vue +35 -0
  103. package/dist/runtime/components/Integrations/WorkflowIconView.vue.d.ts +10 -0
  104. package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +5 -1
  105. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +114 -84
  106. package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +5 -1
  107. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.d.vue.ts +12 -0
  108. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +654 -0
  109. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue.d.ts +12 -0
  110. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.d.vue.ts +36 -0
  111. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +136 -0
  112. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue.d.ts +36 -0
  113. package/dist/runtime/components/Integrations/WorkflowTagsInput.d.vue.ts +14 -0
  114. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +63 -0
  115. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue.d.ts +14 -0
  116. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +35 -15
  117. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.d.vue.ts +13 -0
  118. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +265 -0
  119. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue.d.ts +13 -0
  120. package/dist/runtime/components/Integrations/WorkflowsTable.d.vue.ts +4 -1
  121. package/dist/runtime/components/Integrations/WorkflowsTable.vue +122 -39
  122. package/dist/runtime/components/Integrations/WorkflowsTable.vue.d.ts +4 -1
  123. package/dist/runtime/components/Integrations/workflowLayout.d.ts +5 -0
  124. package/dist/runtime/components/Integrations/workflowLayout.js +5 -3
  125. package/dist/runtime/composables/integrationsApi.d.ts +6 -2
  126. package/dist/runtime/composables/integrationsApi.js +5 -4
  127. package/dist/runtime/composables/useCanvasShortcuts.d.ts +37 -0
  128. package/dist/runtime/composables/useCanvasShortcuts.js +62 -0
  129. package/dist/runtime/composables/useIntegrationsDeadLetters.d.ts +22 -0
  130. package/dist/runtime/composables/useIntegrationsDeadLetters.js +50 -0
  131. package/dist/runtime/composables/useIntegrationsNodes.d.ts +4 -0
  132. package/dist/runtime/composables/useIntegrationsNodes.js +6 -3
  133. package/dist/runtime/composables/useIntegrationsRevisions.d.ts +40 -0
  134. package/dist/runtime/composables/useIntegrationsRevisions.js +51 -0
  135. package/dist/runtime/composables/useIntegrationsRuns.d.ts +21 -1
  136. package/dist/runtime/composables/useIntegrationsRuns.js +92 -4
  137. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +1 -0
  138. package/dist/runtime/composables/useIntegrationsTemplates.js +32 -4
  139. package/dist/runtime/composables/useIntegrationsWorkflows.d.ts +1 -0
  140. package/dist/runtime/composables/useIntegrationsWorkflows.js +57 -1
  141. package/dist/runtime/composables/useMiniMapLayout.d.ts +34 -0
  142. package/dist/runtime/composables/useMiniMapLayout.js +119 -0
  143. package/dist/runtime/composables/useNodeConfigResolve.d.ts +43 -0
  144. package/dist/runtime/composables/useNodeConfigResolve.js +123 -0
  145. package/dist/runtime/composables/useNodeDrag.d.ts +22 -0
  146. package/dist/runtime/composables/useNodeDrag.js +10 -0
  147. package/dist/runtime/composables/usePaletteLayout.d.ts +41 -0
  148. package/dist/runtime/composables/usePaletteLayout.js +113 -0
  149. package/dist/runtime/data/expressionEditor.d.ts +22 -0
  150. package/dist/runtime/data/expressionEditor.js +40 -0
  151. package/dist/runtime/data/integrationNodeCatalog.js +8 -8
  152. package/dist/runtime/data/nodeInputData.d.ts +37 -0
  153. package/dist/runtime/data/nodeInputData.js +62 -0
  154. package/dist/runtime/data/nodeRunData.d.ts +56 -0
  155. package/dist/runtime/data/nodeRunData.js +91 -0
  156. package/dist/runtime/data/payloadForm.d.ts +44 -0
  157. package/dist/runtime/data/payloadForm.js +126 -0
  158. package/dist/runtime/data/workflowIconCatalog.d.ts +34 -0
  159. package/dist/runtime/data/workflowIconCatalog.js +116 -0
  160. package/dist/runtime/pages/integrations/audit.vue +25 -35
  161. package/dist/runtime/pages/integrations/credentials.vue +117 -122
  162. package/dist/runtime/pages/integrations/index.vue +133 -36
  163. package/dist/runtime/pages/integrations/runs.vue +125 -75
  164. package/dist/runtime/pages/integrations/schedules.vue +5 -16
  165. package/dist/runtime/pages/integrations/secrets.vue +1 -3
  166. package/dist/runtime/pages/integrations/workflows/[id].vue +599 -194
  167. package/dist/runtime/pages/integrations/workflows/index.vue +10 -8
  168. package/dist/runtime/types/integrations.d.ts +190 -4
  169. package/dist/runtime/utils/credentialState.d.ts +28 -0
  170. package/dist/runtime/utils/credentialState.js +29 -0
  171. package/dist/runtime/utils/localize.d.ts +3 -1
  172. package/dist/runtime/utils/localize.js +1 -2
  173. package/dist/runtime/utils/statusStyle.d.ts +6 -0
  174. package/dist/runtime/utils/statusStyle.js +15 -0
  175. package/package.json +11 -1
  176. package/dist/runtime/components/Integrations/RunDetailDialog.vue +0 -169
  177. package/dist/runtime/pages/integrations/workflows/new.d.vue.ts +0 -2
  178. package/dist/runtime/pages/integrations/workflows/new.vue +0 -153
  179. package/dist/runtime/pages/integrations/workflows/new.vue.d.ts +0 -2
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed, markRaw, nextTick, ref, watch } from "vue";
2
+ import { computed, markRaw, nextTick, provide, ref, watch } from "vue";
3
3
  import {
4
4
  ConnectionLineType,
5
5
  MarkerType,
@@ -9,24 +9,50 @@ import {
9
9
  useVueFlow
10
10
  } from "@vue-flow/core";
11
11
  import { Background } from "@vue-flow/background";
12
- import { Controls } from "@vue-flow/controls";
13
12
  import { MiniMap } from "@vue-flow/minimap";
14
- import { Widget as LayoutGrid, AddCircle as Plus, MagicStick as Sparkles } from "#solar-icons";
13
+ import {
14
+ Widget as LayoutGrid,
15
+ MagicStick as Sparkles,
16
+ Play,
17
+ MinusCircle as ZoomOut,
18
+ AddCircle as ZoomIn,
19
+ RefreshCircle as Loader2
20
+ } from "#solar-icons";
21
+ import {
22
+ Dialog,
23
+ DialogContent,
24
+ DropdownMenu,
25
+ DropdownMenuContent,
26
+ DropdownMenuItem,
27
+ DropdownMenuSeparator,
28
+ DropdownMenuTrigger,
29
+ TooltipProvider
30
+ } from "@revenexx/studio";
31
+ import { Braces, EllipsisVertical, GripVertical, Group as GroupIcon, Hand, Map as MapIcon, Minus, MousePointer2, Plus, Settings2, StickyNote } from "lucide-vue-next";
15
32
  import "@vue-flow/core/dist/style.css";
16
33
  import "@vue-flow/core/dist/theme-default.css";
17
- import "@vue-flow/controls/dist/style.css";
18
34
  import "@vue-flow/minimap/dist/style.css";
19
35
  import NodePalette from "./NodePalette.vue";
20
36
  import NodeInspector from "./NodeInspector.vue";
37
+ import NodeDynamicOutputsResolver from "./NodeDynamicOutputsResolver.vue";
21
38
  import WorkflowNodeView from "./WorkflowNodeView.vue";
22
39
  import WorkflowTriggerView from "./WorkflowTriggerView.vue";
40
+ import StickyNoteView from "./StickyNoteView.vue";
41
+ import GroupFrameView from "./GroupFrameView.vue";
42
+ import WorkflowContextMenu, {} from "./WorkflowContextMenu.vue";
43
+ import WorkflowVariablesDialog from "./WorkflowVariablesDialog.vue";
44
+ import { useCanvasShortcuts } from "../../composables/useCanvasShortcuts";
45
+ import { usePaletteLayout } from "../../composables/usePaletteLayout";
46
+ import { useMiniMapLayout } from "../../composables/useMiniMapLayout";
47
+ import { toast } from "vue-sonner";
23
48
  import WorkflowEdgeView from "./WorkflowEdgeView.vue";
24
49
  import { layoutWorkflowNodes } from "./workflowLayout";
25
50
  import { TRIGGER_TEMPLATES, findManifest } from "../../data/integrationNodeCatalog";
26
51
  const props = defineProps({
27
- workflow: { type: Object, required: true }
52
+ workflow: { type: Object, required: true },
53
+ runBusy: { type: Boolean, required: false }
28
54
  });
29
- const emit = defineEmits(["change"]);
55
+ const emit = defineEmits(["change", "run", "toggleActive", "openSettings"]);
30
56
  const { keys: secretKeys } = useIntegrationsSecrets();
31
57
  const blob = ref(clone(props.workflow.blob));
32
58
  watch(() => props.workflow.id, () => {
@@ -38,6 +64,12 @@ function clone(v) {
38
64
  }
39
65
  const vfNodes = ref([]);
40
66
  const vfEdges = ref([]);
67
+ const flow = useVueFlow();
68
+ const dynamicOutputs = ref({});
69
+ const dynamicOutputNodes = computed(() => blob.value.nodes.filter((n) => {
70
+ const m = findManifest(n.nodeSlug, n.nodeVersion);
71
+ return (m?.outputs ?? []).some((o) => o.resolveOutputs === true);
72
+ }));
41
73
  function edgeKindFromPort(sourceNodeId, output) {
42
74
  const wn = blob.value.nodes.find((n) => n.id === sourceNodeId);
43
75
  if (!wn) return "default";
@@ -65,40 +97,195 @@ function stripSide(handleId) {
65
97
  const i = handleId.indexOf("__");
66
98
  return i >= 0 ? handleId.slice(0, i) : handleId;
67
99
  }
100
+ function triggerPosition(id, index) {
101
+ const saved = blob.value.triggerPositions?.[id];
102
+ if (saved) return { x: saved.x, y: saved.y };
103
+ return { x: -40, y: 60 + index * 140 };
104
+ }
68
105
  function nodePosition(id) {
69
106
  const wn = blob.value.nodes.find((n) => n.id === id);
70
107
  if (wn) return { x: wn.position?.x ?? 0, y: wn.position?.y ?? 0 };
71
108
  const ti = blob.value.triggers.findIndex((t) => t.id === id);
72
- if (ti >= 0) return { x: -40, y: 60 + ti * 140 };
109
+ if (ti >= 0) return triggerPosition(id, ti);
73
110
  return { x: 0, y: 0 };
74
111
  }
75
- function pickSourceSide(sourceId, targetId) {
76
- const a = nodePosition(sourceId);
77
- const b = nodePosition(targetId);
78
- const dx = b.x - a.x;
79
- const dy = b.y - a.y;
80
- if (Math.abs(dx) * 1.1 > Math.abs(dy)) return dx >= 0 ? "right" : "left";
81
- return dy >= 0 ? "bottom" : "top";
112
+ function pickSourceSide(_sourceId, _targetId) {
113
+ return "bottom";
82
114
  }
83
115
  function oppositeSide(s) {
84
116
  return s === "top" ? "bottom" : s === "right" ? "left" : s === "bottom" ? "top" : "right";
85
117
  }
118
+ function stickyArea(s) {
119
+ return (s.size?.width ?? 240) * (s.size?.height ?? 160);
120
+ }
121
+ const GROUP_PAD = 24;
122
+ const GROUP_LABEL_ROOM = 26;
123
+ function locText(v) {
124
+ if (!v) return "";
125
+ return typeof v === "string" ? v : v.en ?? Object.values(v)[0] ?? "";
126
+ }
127
+ function memberDims(id) {
128
+ const d = flow.findNode(id)?.dimensions;
129
+ return d && d.width > 0 ? d : { width: 220, height: 64 };
130
+ }
131
+ function groupRect(g) {
132
+ let minX = Number.POSITIVE_INFINITY;
133
+ let minY = Number.POSITIVE_INFINITY;
134
+ let maxX = Number.NEGATIVE_INFINITY;
135
+ let maxY = Number.NEGATIVE_INFINITY;
136
+ for (const id of g.nodeIds) {
137
+ const pos = nodePosition(id);
138
+ const dims = memberDims(id);
139
+ minX = Math.min(minX, pos.x);
140
+ minY = Math.min(minY, pos.y);
141
+ maxX = Math.max(maxX, pos.x + dims.width);
142
+ maxY = Math.max(maxY, pos.y + dims.height);
143
+ }
144
+ if (!Number.isFinite(minX)) return g.rect ?? { x: 120, y: 120, width: 220, height: 120 };
145
+ const derived = {
146
+ x: Math.round(minX - GROUP_PAD),
147
+ y: Math.round(minY - GROUP_PAD - GROUP_LABEL_ROOM),
148
+ width: Math.round(maxX - minX + GROUP_PAD * 2),
149
+ height: Math.round(maxY - minY + GROUP_PAD * 2 + GROUP_LABEL_ROOM)
150
+ };
151
+ const r = g.rect;
152
+ if (!r) return derived;
153
+ const x = Math.min(r.x, derived.x);
154
+ const y = Math.min(r.y, derived.y);
155
+ return {
156
+ x,
157
+ y,
158
+ width: Math.max(r.x + r.width, derived.x + derived.width) - x,
159
+ height: Math.max(r.y + r.height, derived.y + derived.height) - y
160
+ };
161
+ }
162
+ function collapsedGroupOf() {
163
+ const m = /* @__PURE__ */ new Map();
164
+ for (const g of blob.value.groups ?? []) {
165
+ if (g.collapsed) for (const id of g.nodeIds) m.set(id, g);
166
+ }
167
+ return m;
168
+ }
169
+ function patchGroup(id, patch) {
170
+ const g = blob.value.groups?.find((x) => x.id === id);
171
+ if (!g) return;
172
+ Object.assign(g, patch);
173
+ syncFromBlob();
174
+ push();
175
+ }
176
+ function ungroup(id) {
177
+ blob.value.groups = (blob.value.groups ?? []).filter((g) => g.id !== id);
178
+ syncFromBlob();
179
+ push();
180
+ }
181
+ function createGroupFromSelection(ids) {
182
+ const valid = ids.filter((id2) => blob.value.nodes.some((n) => n.id === id2) || blob.value.triggers.some((t) => t.id === id2));
183
+ if (valid.length < 1) return;
184
+ for (const g of blob.value.groups ?? []) {
185
+ g.nodeIds = g.nodeIds.filter((x) => !valid.includes(x));
186
+ }
187
+ blob.value.groups = (blob.value.groups ?? []).filter((g) => g.nodeIds.length > 0 || g.rect);
188
+ const id = `g-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
189
+ blob.value.groups = [...blob.value.groups ?? [], {
190
+ id,
191
+ label: `Group ${(blob.value.groups?.length ?? 0) + 1}`,
192
+ nodeIds: valid
193
+ }];
194
+ syncFromBlob();
195
+ push();
196
+ }
197
+ const groupDragOrigin = ref(null);
198
+ const bornIds = ref(/* @__PURE__ */ new Set());
199
+ function markBorn(...ids) {
200
+ for (const id of ids) bornIds.value.add(id);
201
+ bornIds.value = new Set(bornIds.value);
202
+ window.setTimeout(() => {
203
+ for (const id of ids) bornIds.value.delete(id);
204
+ bornIds.value = new Set(bornIds.value);
205
+ syncFromBlob();
206
+ }, 450);
207
+ }
208
+ function bornClass(id) {
209
+ return bornIds.value.has(id) ? "node-pop-in" : void 0;
210
+ }
86
211
  function syncFromBlob() {
212
+ const stickies = [...blob.value.stickies ?? []].sort((a, b) => stickyArea(b) - stickyArea(a));
213
+ const collapsedBy = collapsedGroupOf();
214
+ const groupNodes = (blob.value.groups ?? []).map((g, i) => {
215
+ const rect = groupRect(g);
216
+ return {
217
+ id: g.id,
218
+ type: "group",
219
+ position: { x: rect.x, y: rect.y },
220
+ zIndex: g.collapsed ? 0 : -50 + i,
221
+ class: bornClass(g.id),
222
+ data: {
223
+ group: g,
224
+ rect: { width: rect.width, height: rect.height },
225
+ memberCount: g.nodeIds.length,
226
+ actions: {
227
+ rename: (label) => patchGroup(g.id, { label }),
228
+ setColor: (c) => patchGroup(g.id, { color: c }),
229
+ toggleCollapse: () => patchGroup(g.id, { collapsed: !g.collapsed }),
230
+ ungroup: () => ungroup(g.id),
231
+ resize: (size) => patchGroup(g.id, { rect: { x: rect.x, y: rect.y, width: size.width, height: size.height } })
232
+ }
233
+ }
234
+ };
235
+ });
87
236
  vfNodes.value = [
88
- ...blob.value.triggers.map((t, i) => ({
237
+ ...groupNodes,
238
+ // Stickies render BEHIND workflow/trigger nodes (negative zIndex); larger
239
+ // notes sit lower so small ones stacked on top stay reachable.
240
+ ...stickies.map((s, i) => ({
241
+ id: s.id,
242
+ type: "sticky",
243
+ position: { x: s.position.x, y: s.position.y },
244
+ zIndex: -100 + i,
245
+ class: bornClass(s.id),
246
+ data: {
247
+ sticky: s,
248
+ onUpdate: (patch) => updateSticky(s.id, patch),
249
+ onRemove: () => removeBlobNode(s.id)
250
+ }
251
+ })),
252
+ ...blob.value.triggers.filter((t) => !collapsedBy.has(t.id)).map((t, i) => ({
89
253
  id: t.id,
90
254
  type: "trigger",
91
- position: { x: -40, y: 60 + i * 140 },
255
+ position: triggerPosition(t.id, i),
256
+ class: bornClass(t.id),
92
257
  data: t
93
258
  })),
94
- ...blob.value.nodes.map((n) => ({
259
+ ...blob.value.nodes.filter((n) => !collapsedBy.has(n.id)).map((n) => ({
95
260
  id: n.id,
96
261
  type: "workflow",
97
262
  position: n.position ?? { x: 240, y: 80 },
98
- data: n
263
+ class: bornClass(n.id),
264
+ data: { ...n, __resolvedOutputs: dynamicOutputs.value[n.id] }
99
265
  }))
100
266
  ];
101
- vfEdges.value = blob.value.edges.map((e) => makeVfEdge(e.id, e.from.nodeId, e.to.nodeId, e.from.output));
267
+ const seenEdge = /* @__PURE__ */ new Set();
268
+ const mapped = [];
269
+ for (const e of blob.value.edges) {
270
+ const srcGroup = collapsedBy.get(e.from.nodeId);
271
+ const tgtGroup = collapsedBy.get(e.to.nodeId);
272
+ if (srcGroup && tgtGroup && srcGroup.id === tgtGroup.id) continue;
273
+ const source = srcGroup?.id ?? e.from.nodeId;
274
+ const target = tgtGroup?.id ?? e.to.nodeId;
275
+ const key = `${source}|${srcGroup ? "default" : e.from.output}|${target}`;
276
+ if (seenEdge.has(key)) continue;
277
+ seenEdge.add(key);
278
+ const vf = makeVfEdge(e.id, e.from.nodeId, e.to.nodeId, e.from.output);
279
+ mapped.push({
280
+ ...vf,
281
+ source,
282
+ target,
283
+ sourceHandle: srcGroup ? "default__bottom" : vf.sourceHandle,
284
+ targetHandle: tgtGroup ? "__in__top" : vf.targetHandle
285
+ });
286
+ }
287
+ vfEdges.value = mapped;
288
+ void nextTick(() => flow.updateNodeInternals(vfNodes.value.map((n) => n.id)));
102
289
  }
103
290
  function makeVfEdge(id, sourceId, targetId, output) {
104
291
  const kind = edgeKindFromPort(sourceId, output);
@@ -143,17 +330,68 @@ function edgeColorByKind(kind) {
143
330
  }
144
331
  }
145
332
  syncFromBlob();
146
- const flow = useVueFlow();
147
- flow.onNodeDragStop((evt) => {
148
- const id = evt.node.id;
149
- if (blob.value.triggers.some((t) => t.id === id)) return;
150
- const wn = blob.value.nodes.find((n) => n.id === id);
151
- if (!wn) return;
152
- wn.position = { x: Math.round(evt.node.position.x), y: Math.round(evt.node.position.y) };
333
+ flow.onPaneReady(() => flow.fitView({ padding: 0.2, maxZoom: 1 }));
334
+ flow.onNodeDragStart(({ node }) => {
335
+ if (node.type !== "group") return;
336
+ const g = blob.value.groups?.find((x) => x.id === node.id);
337
+ if (!g) return;
338
+ const members = {};
339
+ for (const id of g.nodeIds) members[id] = nodePosition(id);
340
+ groupDragOrigin.value = { groupId: node.id, frame: { ...node.position }, members };
341
+ });
342
+ flow.onNodeDrag(({ node }) => {
343
+ const origin = groupDragOrigin.value;
344
+ if (!origin || node.type !== "group" || node.id !== origin.groupId) return;
345
+ const dx = node.position.x - origin.frame.x;
346
+ const dy = node.position.y - origin.frame.y;
347
+ for (const [id, pos] of Object.entries(origin.members)) {
348
+ flow.updateNode(id, { position: { x: pos.x + dx, y: pos.y + dy } });
349
+ }
350
+ });
351
+ function persistDraggedNodes(evt) {
352
+ const moved = evt.nodes?.length ? evt.nodes : [evt.node];
353
+ for (const n of moved) {
354
+ const pos = { x: Math.round(n.position.x), y: Math.round(n.position.y) };
355
+ if (n.type === "group") {
356
+ const origin = groupDragOrigin.value;
357
+ if (origin && origin.groupId === n.id) {
358
+ const dx = Math.round(n.position.x - origin.frame.x);
359
+ const dy = Math.round(n.position.y - origin.frame.y);
360
+ for (const [id, memberPos] of Object.entries(origin.members)) {
361
+ const target = { x: Math.round(memberPos.x + dx), y: Math.round(memberPos.y + dy) };
362
+ if (blob.value.triggers.some((t) => t.id === id)) {
363
+ blob.value.triggerPositions = { ...blob.value.triggerPositions ?? {}, [id]: target };
364
+ } else {
365
+ const wn = blob.value.nodes.find((b) => b.id === id);
366
+ if (wn) wn.position = target;
367
+ }
368
+ }
369
+ const g = blob.value.groups?.find((x) => x.id === n.id);
370
+ if (g?.rect) g.rect = { ...g.rect, x: Math.round(g.rect.x + dx), y: Math.round(g.rect.y + dy) };
371
+ groupDragOrigin.value = null;
372
+ }
373
+ continue;
374
+ }
375
+ if (blob.value.triggers.some((t) => t.id === n.id)) {
376
+ blob.value.triggerPositions = { ...blob.value.triggerPositions ?? {}, [n.id]: pos };
377
+ } else if (blob.value.stickies?.some((s) => s.id === n.id)) {
378
+ const st = blob.value.stickies.find((s) => s.id === n.id);
379
+ st.position = pos;
380
+ continue;
381
+ } else {
382
+ const wn = blob.value.nodes.find((b) => b.id === n.id);
383
+ if (wn) wn.position = pos;
384
+ }
385
+ const dims = memberDims(n.id);
386
+ adoptIntoGroupAt(n.id, { x: pos.x + dims.width / 2, y: pos.y + dims.height / 2 });
387
+ }
153
388
  syncFromBlob();
154
389
  push();
155
- });
390
+ }
391
+ flow.onNodeDragStop(persistDraggedNodes);
392
+ flow.onSelectionDragStop(persistDraggedNodes);
156
393
  flow.onConnect((conn) => {
394
+ connectCompleted = true;
157
395
  if (!conn.source || !conn.target) return;
158
396
  const id = `e-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
159
397
  const output = stripSide(conn.sourceHandle) ?? "default";
@@ -165,6 +403,36 @@ flow.onConnect((conn) => {
165
403
  syncFromBlob();
166
404
  push();
167
405
  });
406
+ const pendingConnect = ref(null);
407
+ let connectStart = null;
408
+ let connectCompleted = false;
409
+ flow.onConnectStart(({ nodeId, handleId, handleType }) => {
410
+ connectCompleted = false;
411
+ connectStart = handleType === "source" && nodeId ? { sourceId: nodeId, output: stripSide(handleId) ?? "default" } : null;
412
+ });
413
+ flow.onConnectEnd((event) => {
414
+ const start = connectStart;
415
+ connectStart = null;
416
+ if (!start || connectCompleted) return;
417
+ if (!(event instanceof MouseEvent)) return;
418
+ const target = event.target;
419
+ if (target?.closest(".vue-flow__node, .vue-flow__handle")) return;
420
+ const pos = flow.screenToFlowCoordinate({ x: event.clientX, y: event.clientY });
421
+ pendingConnect.value = { sourceId: start.sourceId, output: start.output, pos: { x: snap(pos.x), y: snap(pos.y) } };
422
+ pendingSpliceEdgeId.value = null;
423
+ void focusPalette();
424
+ toast.info("Pick a node \u2014 it will be added right there, already connected.");
425
+ });
426
+ flow.onEdgeUpdate(({ edge, connection }) => {
427
+ if (!connection.source || !connection.target) return;
428
+ if (connection.source === connection.target) return;
429
+ const be = blob.value.edges.find((e) => e.id === edge.id);
430
+ if (!be) return;
431
+ be.from = { nodeId: connection.source, output: stripSide(connection.sourceHandle) ?? be.from.output };
432
+ be.to = { nodeId: connection.target, input: be.to.input };
433
+ syncFromBlob();
434
+ push();
435
+ });
168
436
  flow.onEdgesChange((changes) => {
169
437
  let mutated = false;
170
438
  for (const c of changes) {
@@ -180,10 +448,22 @@ flow.onNodesChange((changes) => {
180
448
  if (c.type === "remove") removeBlobNode(c.id);
181
449
  }
182
450
  });
451
+ function stripFromGroups(drop) {
452
+ if (!blob.value.groups?.length) return;
453
+ for (const g of blob.value.groups) g.nodeIds = g.nodeIds.filter((x) => !drop.has(x));
454
+ blob.value.groups = blob.value.groups.filter((g) => (g.nodeIds.length > 0 || g.rect) && !drop.has(g.id));
455
+ }
183
456
  function removeBlobNode(id) {
457
+ if (blob.value.groups?.some((g) => g.id === id)) {
458
+ ungroup(id);
459
+ return;
460
+ }
461
+ stripFromGroups(/* @__PURE__ */ new Set([id]));
184
462
  blob.value.nodes = blob.value.nodes.filter((n) => n.id !== id);
463
+ if (blob.value.stickies?.length) blob.value.stickies = blob.value.stickies.filter((s) => s.id !== id);
185
464
  blob.value.triggers = blob.value.triggers.filter((t) => t.id !== id);
186
465
  blob.value.edges = blob.value.edges.filter((e) => e.from.nodeId !== id && e.to.nodeId !== id);
466
+ if (blob.value.triggerPositions?.[id]) delete blob.value.triggerPositions[id];
187
467
  if (selectedId.value === id) selectedId.value = null;
188
468
  syncFromBlob();
189
469
  push();
@@ -191,6 +471,222 @@ function removeBlobNode(id) {
191
471
  function push() {
192
472
  emit("change", clone(blob.value));
193
473
  }
474
+ const STICKY_DEFAULT_SIZE = { width: 240, height: 160 };
475
+ function updateSticky(id, patch) {
476
+ const st = blob.value.stickies?.find((s) => s.id === id);
477
+ if (!st) return;
478
+ Object.assign(st, patch);
479
+ syncFromBlob();
480
+ push();
481
+ }
482
+ const variablesOpen = ref(false);
483
+ const variableCount = computed(() => Object.keys(blob.value.variables ?? {}).length);
484
+ function updateVariables(next) {
485
+ blob.value.variables = Object.keys(next).length ? next : void 0;
486
+ push();
487
+ }
488
+ function addSticky(at) {
489
+ const rect = flow.vueFlowRef.value?.getBoundingClientRect();
490
+ const center = rect ? flow.screenToFlowCoordinate({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }) : { x: 120, y: 120 };
491
+ const sticky = {
492
+ id: `s-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
493
+ // n8n-style starter content: teaches the edit gesture and the supported
494
+ // markdown by example.
495
+ content: "## I'm a note\n**Double-click** to edit me. **Bold**, *italic*, `code`, [links](https://revenexx.com) and\n- lists work",
496
+ position: at ? { x: Math.round(at.x), y: Math.round(at.y) } : {
497
+ x: Math.round(center.x - STICKY_DEFAULT_SIZE.width / 2),
498
+ y: Math.round(center.y - STICKY_DEFAULT_SIZE.height / 2)
499
+ }
500
+ };
501
+ blob.value.stickies = [...blob.value.stickies ?? [], sticky];
502
+ markBorn(sticky.id);
503
+ syncFromBlob();
504
+ push();
505
+ }
506
+ function removeBlobNodes(ids) {
507
+ if (!ids.length) return;
508
+ const drop = new Set(ids);
509
+ stripFromGroups(drop);
510
+ blob.value.nodes = blob.value.nodes.filter((n) => !drop.has(n.id));
511
+ blob.value.triggers = blob.value.triggers.filter((t) => !drop.has(t.id));
512
+ blob.value.edges = blob.value.edges.filter((e) => !drop.has(e.from.nodeId) && !drop.has(e.to.nodeId));
513
+ if (blob.value.stickies?.length) blob.value.stickies = blob.value.stickies.filter((s) => !drop.has(s.id));
514
+ for (const id of ids) {
515
+ if (blob.value.triggerPositions?.[id]) delete blob.value.triggerPositions[id];
516
+ }
517
+ if (selectedId.value && drop.has(selectedId.value)) selectedId.value = null;
518
+ syncFromBlob();
519
+ push();
520
+ }
521
+ function duplicateBlobNodes(ids) {
522
+ const src = blob.value.nodes.filter((n) => ids.includes(n.id));
523
+ if (!src.length) return;
524
+ const idMap = /* @__PURE__ */ new Map();
525
+ const stamp = Date.now();
526
+ src.forEach((n, i) => idMap.set(n.id, `n-${stamp}${i}`));
527
+ const offset = 40;
528
+ const copies = src.map((n) => ({
529
+ ...clone(n),
530
+ id: idMap.get(n.id),
531
+ position: { x: (n.position?.x ?? 0) + offset, y: (n.position?.y ?? 0) + offset }
532
+ }));
533
+ blob.value.nodes.push(...copies);
534
+ for (const e of blob.value.edges.filter((e2) => idMap.has(e2.from.nodeId) && idMap.has(e2.to.nodeId))) {
535
+ blob.value.edges.push({
536
+ id: `e-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`,
537
+ from: { nodeId: idMap.get(e.from.nodeId), output: e.from.output },
538
+ to: { nodeId: idMap.get(e.to.nodeId), input: e.to.input }
539
+ });
540
+ }
541
+ markBorn(...idMap.values());
542
+ syncFromBlob();
543
+ push();
544
+ void nextTick(() => {
545
+ flow.removeSelectedElements();
546
+ flow.addSelectedNodes(flow.getNodes.value.filter((n) => [...idMap.values()].includes(n.id)));
547
+ });
548
+ }
549
+ provide("workflow-node-actions", {
550
+ duplicate: (id) => duplicateBlobNodes([id]),
551
+ remove: (id) => removeBlobNode(id)
552
+ });
553
+ const CLIP_KIND = "revenexx.workflow.nodes";
554
+ async function copyNodes(ids) {
555
+ const nodes = blob.value.nodes.filter((n) => ids.includes(n.id));
556
+ if (!nodes.length) return;
557
+ const inner = new Set(nodes.map((n) => n.id));
558
+ const edges = blob.value.edges.filter((e) => inner.has(e.from.nodeId) && inner.has(e.to.nodeId));
559
+ try {
560
+ await navigator.clipboard.writeText(JSON.stringify({ kind: CLIP_KIND, version: 1, nodes, edges }, null, 2));
561
+ toast.success(nodes.length === 1 ? "Node copied" : `${nodes.length} nodes copied`);
562
+ } catch {
563
+ toast.error("Could not write to the clipboard");
564
+ }
565
+ }
566
+ async function pasteNodes(at) {
567
+ let text = "";
568
+ try {
569
+ text = await navigator.clipboard.readText();
570
+ } catch {
571
+ toast.error("Could not read the clipboard");
572
+ return;
573
+ }
574
+ let payload;
575
+ try {
576
+ payload = JSON.parse(text);
577
+ } catch {
578
+ toast.error("Clipboard does not contain nodes");
579
+ return;
580
+ }
581
+ if (payload?.kind !== CLIP_KIND || !Array.isArray(payload.nodes) || !payload.nodes.length) {
582
+ toast.error("Clipboard does not contain nodes");
583
+ return;
584
+ }
585
+ const idMap = /* @__PURE__ */ new Map();
586
+ const stamp = Date.now();
587
+ payload.nodes.forEach((n, i) => idMap.set(n.id, `n-${stamp}${i}`));
588
+ const minX = Math.min(...payload.nodes.map((n) => n.position?.x ?? 0));
589
+ const minY = Math.min(...payload.nodes.map((n) => n.position?.y ?? 0));
590
+ const dx = at ? Math.round(at.x - minX) : 40;
591
+ const dy = at ? Math.round(at.y - minY) : 40;
592
+ blob.value.nodes.push(...payload.nodes.map((n) => ({
593
+ ...clone(n),
594
+ id: idMap.get(n.id),
595
+ position: { x: (n.position?.x ?? 0) + dx, y: (n.position?.y ?? 0) + dy }
596
+ })));
597
+ for (const e of payload.edges ?? []) {
598
+ if (!idMap.has(e.from.nodeId) || !idMap.has(e.to.nodeId)) continue;
599
+ blob.value.edges.push({
600
+ id: `e-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`,
601
+ from: { nodeId: idMap.get(e.from.nodeId), output: e.from.output },
602
+ to: { nodeId: idMap.get(e.to.nodeId), input: e.to.input }
603
+ });
604
+ }
605
+ markBorn(...idMap.values());
606
+ syncFromBlob();
607
+ push();
608
+ void nextTick(() => {
609
+ flow.removeSelectedElements();
610
+ flow.addSelectedNodes(flow.getNodes.value.filter((n) => [...idMap.values()].includes(n.id)));
611
+ });
612
+ }
613
+ const menu = ref({ kind: "canvas", targetId: null, flowPos: null });
614
+ function eventFlowPos(event) {
615
+ if (!("clientX" in event)) return null;
616
+ return flow.screenToFlowCoordinate({ x: event.clientX, y: event.clientY });
617
+ }
618
+ flow.onNodeContextMenu(({ event, node }) => {
619
+ const selected = flow.getSelectedNodes.value;
620
+ if (selected.length > 1 && selected.some((n) => n.id === node.id)) {
621
+ menu.value = { kind: "selection", targetId: null, flowPos: eventFlowPos(event) };
622
+ } else {
623
+ flow.removeSelectedElements();
624
+ flow.addSelectedNodes([node]);
625
+ menu.value = { kind: "node", targetId: node.id, flowPos: eventFlowPos(event) };
626
+ }
627
+ });
628
+ flow.onSelectionContextMenu(({ event }) => {
629
+ menu.value = { kind: "selection", targetId: null, flowPos: eventFlowPos(event) };
630
+ });
631
+ flow.onPaneContextMenu((event) => {
632
+ menu.value = { kind: "canvas", targetId: null, flowPos: eventFlowPos(event) };
633
+ });
634
+ function onMenuAction(id) {
635
+ const selectedIds = flow.getSelectedNodes.value.map((n) => n.id);
636
+ switch (id) {
637
+ case "open":
638
+ if (menu.value.targetId) selectedId.value = menu.value.targetId;
639
+ break;
640
+ case "rename":
641
+ if (menu.value.targetId) {
642
+ renameIntent.value = true;
643
+ selectedId.value = menu.value.targetId;
644
+ }
645
+ break;
646
+ case "copy":
647
+ void copyNodes(menu.value.kind === "node" && menu.value.targetId ? [menu.value.targetId] : selectedIds);
648
+ break;
649
+ case "duplicate":
650
+ duplicateBlobNodes(menu.value.kind === "node" && menu.value.targetId ? [menu.value.targetId] : selectedIds);
651
+ break;
652
+ case "paste":
653
+ void pasteNodes(menu.value.flowPos ?? void 0);
654
+ break;
655
+ case "run":
656
+ emit("run");
657
+ break;
658
+ case "tidy-up":
659
+ void tidyUp();
660
+ break;
661
+ case "fit-view":
662
+ flow.fitView({ padding: 0.2, duration: 400, maxZoom: 1 });
663
+ break;
664
+ case "add-node":
665
+ void focusPalette();
666
+ break;
667
+ case "add-sticky":
668
+ addSticky(menu.value.flowPos ?? void 0);
669
+ break;
670
+ case "group":
671
+ createGroupFromSelection(selectedIds);
672
+ break;
673
+ case "select-all":
674
+ flow.addSelectedNodes(flow.getNodes.value);
675
+ break;
676
+ case "clear-selection":
677
+ flow.removeSelectedElements();
678
+ break;
679
+ case "delete":
680
+ if (menu.value.kind === "node" && menu.value.targetId) removeBlobNode(menu.value.targetId);
681
+ else removeBlobNodes(selectedIds);
682
+ break;
683
+ }
684
+ }
685
+ function onResolvedOutputs(nodeId, outputs) {
686
+ dynamicOutputs.value = { ...dynamicOutputs.value, [nodeId]: outputs };
687
+ syncFromBlob();
688
+ nextTick(() => flow.updateNodeInternals([nodeId]));
689
+ }
194
690
  function isValidConnection(conn) {
195
691
  if (!conn.source || !conn.target) return false;
196
692
  if (conn.source === conn.target) return false;
@@ -202,22 +698,33 @@ const selection = computed(() => {
202
698
  return blob.value.nodes.find((n) => n.id === selectedId.value) ?? blob.value.triggers.find((t) => t.id === selectedId.value) ?? null;
203
699
  });
204
700
  flow.onNodeClick((evt) => {
701
+ if (evt.node.type === "sticky" || evt.node.type === "group") return;
205
702
  selectedId.value = evt.node.id;
206
703
  });
207
704
  flow.onPaneClick(() => {
208
705
  selectedId.value = null;
209
706
  });
210
- const paletteOpen = ref(false);
211
- watch(selectedId, (next) => {
212
- if (next) paletteOpen.value = false;
707
+ function localLabel(v) {
708
+ if (!v) return "";
709
+ return typeof v === "string" ? v : v.en ?? Object.values(v)[0] ?? "";
710
+ }
711
+ const selectedUpstreamOutputs = computed(() => {
712
+ const id = selectedId.value;
713
+ if (!id) return [];
714
+ const isNode = blob.value.nodes.some((n) => n.id === id);
715
+ if (!isNode) return [];
716
+ return blob.value.edges.filter((e) => e.to.nodeId === id).flatMap((e) => {
717
+ const source = blob.value.nodes.find((n) => n.id === e.from.nodeId);
718
+ if (!source) return [];
719
+ const portDef = (findManifest(source.nodeSlug, source.nodeVersion)?.outputs ?? []).find((o) => o.name === e.from.output);
720
+ return [{
721
+ nodeId: source.id,
722
+ nodeName: localLabel(source.name) || source.nodeSlug,
723
+ output: e.from.output,
724
+ fields: portDef?.fields
725
+ }];
726
+ });
213
727
  });
214
- watch(
215
- () => blob.value.nodes.length + blob.value.triggers.length,
216
- (count, prev) => {
217
- if (prev === void 0 && count === 0) paletteOpen.value = true;
218
- },
219
- { immediate: true }
220
- );
221
728
  function applyInspectorUpdate(next) {
222
729
  const id = next.id;
223
730
  const triggerIdx = blob.value.triggers.findIndex((t) => t.id === id);
@@ -230,21 +737,86 @@ function applyInspectorUpdate(next) {
230
737
  syncFromBlob();
231
738
  push();
232
739
  }
740
+ const nodeDrag = useNodeDrag();
741
+ const dropGhost = ref(null);
742
+ const GRID = 20;
743
+ function snap(v) {
744
+ return Math.round(v / GRID) * GRID;
745
+ }
746
+ const dragActive = computed(() => !!nodeDrag.dragging.value);
747
+ watch(dragActive, (on) => {
748
+ if (!on) dropGhost.value = null;
749
+ });
750
+ const ghostRect = computed(() => {
751
+ if (!dropGhost.value) return null;
752
+ const vp = flow.viewport.value;
753
+ const kind = nodeDrag.dragging.value?.kind;
754
+ const size = kind === "group" ? { width: 320, height: 200 } : { width: kind === "trigger" ? 200 : 220, height: 58 };
755
+ return {
756
+ left: dropGhost.value.x * vp.zoom + vp.x,
757
+ top: dropGhost.value.y * vp.zoom + vp.y,
758
+ width: size.width * vp.zoom,
759
+ height: size.height * vp.zoom
760
+ };
761
+ });
233
762
  function onDragOver(ev) {
234
763
  ev.preventDefault();
235
764
  if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
765
+ if (!nodeDrag.dragging.value) return;
766
+ const p = flow.screenToFlowCoordinate({ x: ev.clientX, y: ev.clientY });
767
+ dropGhost.value = { x: snap(p.x), y: snap(p.y) };
236
768
  }
237
- function onDrop(ev) {
238
- ev.preventDefault();
239
- const raw = ev.dataTransfer?.getData("application/integration-node");
240
- if (!raw) return;
241
- const payload = JSON.parse(raw);
242
- const pos = flow.screenToFlowCoordinate({ x: ev.clientX, y: ev.clientY });
769
+ function onDragLeave(ev) {
770
+ const target = ev.currentTarget;
771
+ if (target && ev.relatedTarget instanceof Node && target.contains(ev.relatedTarget)) return;
772
+ dropGhost.value = null;
773
+ }
774
+ const EMPTY_GROUP_SIZE = { width: 320, height: 200 };
775
+ function groupAtPoint(pos) {
776
+ for (const g of blob.value.groups ?? []) {
777
+ if (g.collapsed) continue;
778
+ const r = groupRect(g);
779
+ if (pos.x >= r.x && pos.x <= r.x + r.width && pos.y >= r.y && pos.y <= r.y + r.height) return g;
780
+ }
781
+ return null;
782
+ }
783
+ function addGroupFrame() {
784
+ const rect = flow.vueFlowRef.value?.getBoundingClientRect();
785
+ const center = rect ? flow.screenToFlowCoordinate({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }) : { x: 120, y: 120 };
786
+ addFromPayload({ kind: "group" }, {
787
+ x: center.x - EMPTY_GROUP_SIZE.width / 2,
788
+ y: center.y - EMPTY_GROUP_SIZE.height / 2
789
+ });
790
+ }
791
+ function adoptIntoGroupAt(id, pos) {
792
+ const g = groupAtPoint(pos);
793
+ if (!g || g.nodeIds.includes(id)) return;
794
+ for (const other of blob.value.groups ?? []) other.nodeIds = other.nodeIds.filter((x) => x !== id);
795
+ g.nodeIds.push(id);
796
+ }
797
+ function addFromPayload(payload, pos) {
798
+ const at = { x: snap(pos.x), y: snap(pos.y) };
799
+ if (payload.kind === "group") {
800
+ const id2 = `g-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
801
+ blob.value.groups = [...blob.value.groups ?? [], {
802
+ id: id2,
803
+ label: `Group ${(blob.value.groups?.length ?? 0) + 1}`,
804
+ nodeIds: [],
805
+ rect: { x: at.x, y: at.y, ...EMPTY_GROUP_SIZE }
806
+ }];
807
+ markBorn(id2);
808
+ syncFromBlob();
809
+ push();
810
+ return;
811
+ }
243
812
  if (payload.kind === "trigger") {
244
813
  const tpl = TRIGGER_TEMPLATES.find((t) => t.type === payload.type);
245
814
  if (!tpl) return;
246
- const id2 = `trg-${Date.now()}`;
815
+ const id2 = crypto.randomUUID();
247
816
  blob.value.triggers.push({ id: id2, type: tpl.type, config: { ...tpl.defaultConfig } });
817
+ blob.value.triggerPositions = { ...blob.value.triggerPositions ?? {}, [id2]: at };
818
+ adoptIntoGroupAt(id2, at);
819
+ markBorn(id2);
248
820
  syncFromBlob();
249
821
  selectedId.value = id2;
250
822
  push();
@@ -258,8 +830,95 @@ function onDrop(ev) {
258
830
  nodeSlug: manifest.slug,
259
831
  nodeVersion: manifest.version,
260
832
  config: defaultConfigFor(manifest.config ?? []),
261
- position: { x: Math.round(pos.x), y: Math.round(pos.y) }
833
+ position: at
262
834
  });
835
+ adoptIntoGroupAt(id, at);
836
+ markBorn(id);
837
+ syncFromBlob();
838
+ selectedId.value = id;
839
+ push();
840
+ }
841
+ function onDrop(ev) {
842
+ ev.preventDefault();
843
+ dropGhost.value = null;
844
+ nodeDrag.end();
845
+ pendingSpliceEdgeId.value = null;
846
+ pendingConnect.value = null;
847
+ const raw = ev.dataTransfer?.getData("application/integration-node");
848
+ if (!raw) return;
849
+ const payload = JSON.parse(raw);
850
+ addFromPayload(payload, flow.screenToFlowCoordinate({ x: ev.clientX, y: ev.clientY }));
851
+ }
852
+ function onPaletteInsert(payload) {
853
+ if (pendingConnect.value && payload.kind === "manifest") {
854
+ const pc = pendingConnect.value;
855
+ pendingConnect.value = null;
856
+ const manifest = findManifest(payload.slug, payload.version);
857
+ if (!manifest) return;
858
+ const id = `n-${Date.now()}`;
859
+ blob.value.nodes.push({
860
+ id,
861
+ nodeSlug: manifest.slug,
862
+ nodeVersion: manifest.version,
863
+ config: defaultConfigFor(manifest.config ?? []),
864
+ position: { x: pc.pos.x, y: pc.pos.y }
865
+ });
866
+ blob.value.edges.push({
867
+ id: `e-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`,
868
+ from: { nodeId: pc.sourceId, output: pc.output },
869
+ to: { nodeId: id, input: void 0 }
870
+ });
871
+ markBorn(id);
872
+ syncFromBlob();
873
+ selectedId.value = id;
874
+ push();
875
+ return;
876
+ }
877
+ pendingConnect.value = null;
878
+ if (pendingSpliceEdgeId.value && payload.kind === "manifest") {
879
+ spliceIntoEdge(pendingSpliceEdgeId.value, payload);
880
+ pendingSpliceEdgeId.value = null;
881
+ return;
882
+ }
883
+ pendingSpliceEdgeId.value = null;
884
+ const rect = flow.vueFlowRef.value?.getBoundingClientRect();
885
+ const center = rect ? flow.screenToFlowCoordinate({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }) : { x: 120, y: 120 };
886
+ addFromPayload(payload, center);
887
+ }
888
+ const pendingSpliceEdgeId = ref(null);
889
+ provide("workflow-edge-actions", {
890
+ splice: (edgeId) => {
891
+ if (!blob.value.edges.some((e) => e.id === edgeId)) return;
892
+ pendingSpliceEdgeId.value = edgeId;
893
+ void focusPalette();
894
+ toast.info("Pick a node from the palette \u2014 it will be inserted into the connection.");
895
+ }
896
+ });
897
+ function primaryOutputName(slug, version) {
898
+ const m = findManifest(slug, version);
899
+ return (m?.outputs ?? []).find((o) => o.name && o.kind !== "error")?.name ?? "default";
900
+ }
901
+ function spliceIntoEdge(edgeId, payload) {
902
+ const edge = blob.value.edges.find((e) => e.id === edgeId);
903
+ const manifest = findManifest(payload.slug, payload.version);
904
+ if (!edge || !manifest) return;
905
+ const a = nodePosition(edge.from.nodeId);
906
+ const b = nodePosition(edge.to.nodeId);
907
+ const id = `n-${Date.now()}`;
908
+ blob.value.nodes.push({
909
+ id,
910
+ nodeSlug: manifest.slug,
911
+ nodeVersion: manifest.version,
912
+ config: defaultConfigFor(manifest.config ?? []),
913
+ position: { x: snap((a.x + b.x) / 2), y: snap((a.y + b.y) / 2) }
914
+ });
915
+ blob.value.edges = blob.value.edges.filter((e) => e.id !== edgeId);
916
+ const stamp = Date.now().toString(36);
917
+ blob.value.edges.push(
918
+ { id: `e-${stamp}a${Math.random().toString(36).slice(2, 6)}`, from: { nodeId: edge.from.nodeId, output: edge.from.output }, to: { nodeId: id, input: void 0 } },
919
+ { id: `e-${stamp}b${Math.random().toString(36).slice(2, 6)}`, from: { nodeId: id, output: primaryOutputName(payload.slug, payload.version) }, to: { nodeId: edge.to.nodeId, input: edge.to.input } }
920
+ );
921
+ markBorn(id);
263
922
  syncFromBlob();
264
923
  selectedId.value = id;
265
924
  push();
@@ -271,22 +930,262 @@ function defaultConfigFor(fields = []) {
271
930
  }
272
931
  return out;
273
932
  }
933
+ function nodeDims(id) {
934
+ const d = flow.findNode(id)?.dimensions;
935
+ return d && d.width > 0 ? d : { width: 220, height: 64 };
936
+ }
274
937
  async function tidyUp() {
275
- const laid = layoutWorkflowNodes(vfNodes.value, vfEdges.value, { direction: "LR" });
276
- vfNodes.value = laid;
938
+ const containment = /* @__PURE__ */ new Map();
939
+ for (const s of blob.value.stickies ?? []) {
940
+ const sw = s.size?.width ?? 240;
941
+ const sh = s.size?.height ?? 160;
942
+ const contained = [];
943
+ for (const n of vfNodes.value) {
944
+ if (n.type === "sticky") continue;
945
+ const dims = nodeDims(n.id);
946
+ const cx = n.position.x + dims.width / 2;
947
+ const cy = n.position.y + dims.height / 2;
948
+ if (cx >= s.position.x && cx <= s.position.x + sw && cy >= s.position.y && cy <= s.position.y + sh) {
949
+ contained.push(n.id);
950
+ }
951
+ }
952
+ if (contained.length) containment.set(s.id, contained);
953
+ }
954
+ const graphNodes = vfNodes.value.filter((n) => n.type !== "sticky" && n.type !== "group");
955
+ const memberToGroup = /* @__PURE__ */ new Map();
956
+ for (const g of blob.value.groups ?? []) for (const id of g.nodeIds) memberToGroup.set(id, g);
957
+ const internal = /* @__PURE__ */ new Map();
958
+ for (const g of blob.value.groups ?? []) {
959
+ if (g.collapsed) continue;
960
+ const members = graphNodes.filter((n) => g.nodeIds.includes(n.id));
961
+ if (!members.length) continue;
962
+ const memberEdges = vfEdges.value.filter((e) => g.nodeIds.includes(e.source) && g.nodeIds.includes(e.target));
963
+ const laidMembers = layoutWorkflowNodes(members, memberEdges, { direction: "TB", nodeWidth: 240, nodeHeight: 90, rankSeparation: 60, nodeSeparation: 30 });
964
+ let maxX = 0;
965
+ let maxY = 0;
966
+ for (const m of laidMembers) {
967
+ const dims = nodeDims(m.id);
968
+ maxX = Math.max(maxX, m.position.x + dims.width);
969
+ maxY = Math.max(maxY, m.position.y + dims.height);
970
+ }
971
+ internal.set(g.id, { nodes: laidMembers, width: maxX + GROUP_PAD * 2, height: maxY + GROUP_PAD * 2 + GROUP_LABEL_ROOM });
972
+ }
973
+ const quotientUnit = (id) => memberToGroup.get(id)?.id ?? id;
974
+ const quotientNodes = [];
975
+ const seenUnit = /* @__PURE__ */ new Set();
976
+ for (const n of graphNodes) {
977
+ const unit = quotientUnit(n.id);
978
+ if (seenUnit.has(unit)) continue;
979
+ seenUnit.add(unit);
980
+ quotientNodes.push(unit === n.id ? n : { id: unit, position: { x: 0, y: 0 } });
981
+ }
982
+ const sizes = {};
983
+ for (const [gid, info] of internal) sizes[gid] = { width: info.width, height: info.height };
984
+ for (const g of blob.value.groups ?? []) {
985
+ if (g.collapsed) sizes[g.id] = { width: 240, height: 90 };
986
+ }
987
+ const seenQe = /* @__PURE__ */ new Set();
988
+ const quotientEdges = [];
989
+ for (const e of vfEdges.value) {
990
+ const su = quotientUnit(e.source);
991
+ const tu = quotientUnit(e.target);
992
+ if (su === tu) continue;
993
+ const key = `${su}|${tu}`;
994
+ if (seenQe.has(key)) continue;
995
+ seenQe.add(key);
996
+ quotientEdges.push({ id: key, source: su, target: tu });
997
+ }
998
+ const laidQuotient = layoutWorkflowNodes(quotientNodes, quotientEdges, { direction: "TB", sizes });
999
+ const unitPos = new Map(laidQuotient.map((n) => [n.id, n.position]));
1000
+ const laid = graphNodes.map((n) => {
1001
+ const g = memberToGroup.get(n.id);
1002
+ if (!g) {
1003
+ const pos = unitPos.get(n.id);
1004
+ return pos ? { ...n, position: pos } : n;
1005
+ }
1006
+ const anchor = unitPos.get(g.id);
1007
+ if (!anchor) return n;
1008
+ if (g.collapsed) {
1009
+ const idx = g.nodeIds.indexOf(n.id);
1010
+ return { ...n, position: { x: anchor.x, y: anchor.y + idx * 30 } };
1011
+ }
1012
+ const inner = internal.get(g.id)?.nodes.find((m) => m.id === n.id);
1013
+ if (!inner) return n;
1014
+ return { ...n, position: { x: anchor.x + GROUP_PAD + inner.position.x, y: anchor.y + GROUP_PAD + GROUP_LABEL_ROOM + inner.position.y } };
1015
+ });
277
1016
  for (const n of laid) {
1017
+ const pos = { x: Math.round(n.position.x), y: Math.round(n.position.y) };
278
1018
  if (n.type === "workflow") {
279
1019
  const wn = blob.value.nodes.find((b) => b.id === n.id);
280
- if (wn) wn.position = { x: Math.round(n.position.x), y: Math.round(n.position.y) };
1020
+ if (wn) wn.position = pos;
1021
+ } else if (n.type === "trigger") {
1022
+ blob.value.triggerPositions = { ...blob.value.triggerPositions ?? {}, [n.id]: pos };
1023
+ }
1024
+ }
1025
+ for (const g of blob.value.groups ?? []) delete g.rect;
1026
+ const PAD = 24;
1027
+ const LABEL_ROOM = 20;
1028
+ for (const [stickyId, ids] of containment) {
1029
+ const st = blob.value.stickies?.find((x) => x.id === stickyId);
1030
+ if (!st) continue;
1031
+ let minX = Number.POSITIVE_INFINITY;
1032
+ let minY = Number.POSITIVE_INFINITY;
1033
+ let maxX = Number.NEGATIVE_INFINITY;
1034
+ let maxY = Number.NEGATIVE_INFINITY;
1035
+ for (const id of ids) {
1036
+ const ln = laid.find((l) => l.id === id);
1037
+ if (!ln) continue;
1038
+ const dims = nodeDims(id);
1039
+ minX = Math.min(minX, ln.position.x);
1040
+ minY = Math.min(minY, ln.position.y);
1041
+ maxX = Math.max(maxX, ln.position.x + dims.width);
1042
+ maxY = Math.max(maxY, ln.position.y + dims.height);
281
1043
  }
1044
+ if (!Number.isFinite(minX)) continue;
1045
+ st.position = { x: Math.round(minX - PAD), y: Math.round(minY - PAD - LABEL_ROOM) };
1046
+ st.size = {
1047
+ width: Math.max(150, Math.round(maxX - minX + PAD * 2)),
1048
+ height: Math.max(80, Math.round(maxY - minY + PAD * 2 + LABEL_ROOM))
1049
+ };
282
1050
  }
1051
+ syncFromBlob();
283
1052
  push();
284
1053
  await nextTick();
285
- flow.fitView({ padding: 0.2, duration: 400 });
1054
+ flow.fitView({ padding: 0.2, duration: 400, maxZoom: 1 });
1055
+ }
1056
+ const POINTER_MODE_KEY = "integrations.editor.pointerMode";
1057
+ const pointerMode = ref("select");
1058
+ try {
1059
+ const stored = localStorage.getItem(POINTER_MODE_KEY);
1060
+ if (stored === "hand" || stored === "select") pointerMode.value = stored;
1061
+ } catch {
286
1062
  }
1063
+ function setPointerMode(mode) {
1064
+ pointerMode.value = mode;
1065
+ try {
1066
+ localStorage.setItem(POINTER_MODE_KEY, mode);
1067
+ } catch {
1068
+ }
1069
+ }
1070
+ const zoomPct = computed(() => `${Math.round((flow.viewport.value?.zoom ?? 1) * 100)}%`);
1071
+ const ZOOM_PRESETS = [50, 75, 100, 150, 200, 300, 400];
1072
+ const MIN_ZOOM_PCT = 25;
1073
+ const MAX_ZOOM_PCT = 400;
1074
+ const zoomMenuOpen = ref(false);
1075
+ const zoomInputText = ref("");
1076
+ function setZoomPct(pct) {
1077
+ const clamped = Math.min(MAX_ZOOM_PCT, Math.max(MIN_ZOOM_PCT, Math.round(pct)));
1078
+ void flow.zoomTo(clamped / 100, { duration: 200 });
1079
+ zoomMenuOpen.value = false;
1080
+ }
1081
+ function applyZoomInput() {
1082
+ const parsed = Number.parseFloat(zoomInputText.value.replace("%", "").trim());
1083
+ if (!Number.isFinite(parsed) || parsed <= 0) return;
1084
+ setZoomPct(parsed);
1085
+ }
1086
+ watch(zoomMenuOpen, (open) => {
1087
+ if (open) zoomInputText.value = String(Math.round((flow.viewport.value?.zoom ?? 1) * 100));
1088
+ });
1089
+ const paletteRef = ref(null);
1090
+ const renameIntent = ref(false);
1091
+ const canvasWrapRef = ref(null);
1092
+ const paletteShellRef = ref(null);
1093
+ const palette = usePaletteLayout(canvasWrapRef);
1094
+ const paletteShellClass = computed(() => {
1095
+ if (palette.mode.value === "docked-left") return "left-3 top-3 bottom-16 origin-top-left";
1096
+ if (palette.mode.value === "docked-right") return "right-3 top-3 bottom-16 origin-top-right";
1097
+ return "h-[min(70vh,34rem)] origin-top-left";
1098
+ });
1099
+ const palettePillClass = computed(() => {
1100
+ if (palette.mode.value === "docked-left") return "left-3 top-3 origin-top-left";
1101
+ if (palette.mode.value === "docked-right") return "right-3 top-3 origin-top-right";
1102
+ return "origin-top-left";
1103
+ });
1104
+ const minimapShellRef = ref(null);
1105
+ const minimapLayout = useMiniMapLayout(canvasWrapRef);
1106
+ const MINIMAP_CORNER_CLASS = {
1107
+ "top-left": "left-3 top-3",
1108
+ "top-right": "right-3 top-3",
1109
+ "bottom-left": "bottom-16 left-3",
1110
+ "bottom-right": "bottom-16 right-3"
1111
+ };
1112
+ const minimapPlacementClass = computed(() => minimapLayout.mode.value === "floating" ? "" : MINIMAP_CORNER_CLASS[minimapLayout.mode.value]);
1113
+ const minimapPreviewClass = computed(() => minimapLayout.dockPreview.value ? MINIMAP_CORNER_CLASS[minimapLayout.dockPreview.value] : "");
1114
+ async function focusPalette() {
1115
+ if (palette.collapsed.value) {
1116
+ palette.setCollapsed(false);
1117
+ await nextTick();
1118
+ }
1119
+ paletteRef.value?.focusSearch();
1120
+ }
1121
+ const lightboxOpen = computed(() => !!selection.value);
1122
+ const singleSelected = computed(() => flow.getSelectedNodes.value.length === 1 ? flow.getSelectedNodes.value[0] : null);
1123
+ useCanvasShortcuts(() => ({
1124
+ "enter": {
1125
+ disabled: () => !singleSelected.value,
1126
+ run: () => {
1127
+ selectedId.value = singleSelected.value.id;
1128
+ }
1129
+ },
1130
+ "f2|space": {
1131
+ disabled: () => !singleSelected.value,
1132
+ run: () => {
1133
+ renameIntent.value = true;
1134
+ selectedId.value = singleSelected.value.id;
1135
+ }
1136
+ },
1137
+ "mod+a": () => flow.addSelectedNodes(flow.getNodes.value),
1138
+ "mod+c": {
1139
+ disabled: () => !flow.getSelectedNodes.value.length,
1140
+ run: () => {
1141
+ void copyNodes(flow.getSelectedNodes.value.map((n) => n.id));
1142
+ }
1143
+ },
1144
+ "mod+v": () => {
1145
+ void pasteNodes();
1146
+ },
1147
+ "mod+d": {
1148
+ disabled: () => !flow.getSelectedNodes.value.length,
1149
+ run: () => duplicateBlobNodes(flow.getSelectedNodes.value.map((n) => n.id))
1150
+ },
1151
+ "mod+g": {
1152
+ disabled: () => !flow.getSelectedNodes.value.length,
1153
+ run: () => createGroupFromSelection(flow.getSelectedNodes.value.map((n) => n.id))
1154
+ },
1155
+ "mod+enter": { disabled: () => !!props.runBusy, run: () => emit("run") },
1156
+ "shift+alt+t": () => {
1157
+ void tidyUp();
1158
+ },
1159
+ "shift+s": () => addSticky(),
1160
+ "v": () => setPointerMode("select"),
1161
+ "h": () => setPointerMode("hand"),
1162
+ "shift+a": () => emit("toggleActive"),
1163
+ "shift+v": () => {
1164
+ variablesOpen.value = true;
1165
+ },
1166
+ "shift+alt+s": () => emit("openSettings"),
1167
+ "-": () => {
1168
+ void flow.zoomOut({ duration: 150 });
1169
+ },
1170
+ "=": () => {
1171
+ void flow.zoomIn({ duration: 150 });
1172
+ },
1173
+ "0": () => setZoomPct(100),
1174
+ "1": () => flow.fitView({ padding: 0.2, duration: 400, maxZoom: 1 }),
1175
+ "tab": () => {
1176
+ void focusPalette();
1177
+ },
1178
+ "escape": () => {
1179
+ pendingSpliceEdgeId.value = null;
1180
+ pendingConnect.value = null;
1181
+ flow.removeSelectedElements();
1182
+ }
1183
+ }), { disabled: lightboxOpen });
287
1184
  const nodeTypes = {
288
1185
  workflow: markRaw(WorkflowNodeView),
289
- trigger: markRaw(WorkflowTriggerView)
1186
+ trigger: markRaw(WorkflowTriggerView),
1187
+ sticky: markRaw(StickyNoteView),
1188
+ group: markRaw(GroupFrameView)
290
1189
  };
291
1190
  const edgeTypes = {
292
1191
  flow: markRaw(WorkflowEdgeView)
@@ -294,80 +1193,447 @@ const edgeTypes = {
294
1193
  </script>
295
1194
 
296
1195
  <template>
297
- <div class="flex h-full min-h-0 flex-1 bg-[#f7f6fb] relative">
298
- <!-- Slide-in node palette: collapses to 0-width unless `paletteOpen`. -->
1196
+ <div class="h-full min-h-0 flex-1 bg-[#f7f6fb] relative">
299
1197
  <div
300
- class="shrink-0 overflow-hidden transition-[width] duration-200 ease-out"
301
- :class="paletteOpen ? 'w-60' : 'w-0'"
302
- >
303
- <NodePalette />
304
- </div>
305
-
306
- <div
307
- class="relative flex-1 min-w-0"
1198
+ ref="canvasWrapRef"
1199
+ class="relative h-full w-full transition-shadow duration-150"
1200
+ :class="dragActive ? 'canvas-drop-active' : ''"
308
1201
  @drop="onDrop"
309
1202
  @dragover="onDragOver"
1203
+ @dragleave="onDragLeave"
310
1204
  >
311
- <VueFlow
312
- v-model:nodes="vfNodes"
313
- v-model:edges="vfEdges"
314
- :node-types="nodeTypes"
315
- :edge-types="edgeTypes"
316
- :default-edge-options="{ type: 'flow' }"
317
- :connection-line-type="ConnectionLineType.SmoothStep"
318
- :connection-line-style="{ stroke: '#94a3b8', strokeWidth: 1.75, strokeDasharray: '4 4' }"
319
- :is-valid-connection="isValidConnection"
320
- :snap-to-grid="true"
321
- :snap-grid="[20, 20]"
322
- :selection-mode="SelectionMode.Partial"
323
- :pan-on-scroll-mode="PanOnScrollMode.Free"
324
- :delete-key-code="['Backspace', 'Delete']"
325
- :min-zoom="0.25"
326
- :max-zoom="2"
327
- fit-view-on-init
1205
+ <WorkflowContextMenu
1206
+ :kind="menu.kind"
1207
+ :selection-count="flow.getSelectedNodes.value.length"
1208
+ :total-nodes="vfNodes.length"
1209
+ :target-is-trigger="!!menu.targetId && blob.triggers.some((t) => t.id === menu.targetId)"
1210
+ :run-busy="runBusy"
1211
+ @action="onMenuAction"
328
1212
  >
329
- <Background pattern-color="#cbd5e1" :gap="20" :size="1.2" />
330
- <Controls position="bottom-right" :show-interactive="false" />
331
- <MiniMap
332
- position="bottom-left"
333
- pannable
334
- zoomable
335
- :node-stroke-width="3"
336
- :node-color="(n) => n.type === 'trigger' ? '#10b981' : '#94a3b8'"
337
- :mask-color="'rgba(15, 23, 42, 0.06)'"
338
- />
339
- </VueFlow>
1213
+ <VueFlow
1214
+ v-model:nodes="vfNodes"
1215
+ v-model:edges="vfEdges"
1216
+ :node-types="nodeTypes"
1217
+ :edge-types="edgeTypes"
1218
+ :default-edge-options="{ type: 'flow' }"
1219
+ :connection-line-type="ConnectionLineType.Step"
1220
+ :connection-line-style="{ stroke: '#94a3b8', strokeWidth: 1.75, strokeDasharray: '4 4' }"
1221
+ :is-valid-connection="isValidConnection"
1222
+ :snap-to-grid="true"
1223
+ :snap-grid="[20, 20]"
1224
+ :selection-mode="SelectionMode.Partial"
1225
+ :selection-key-code="pointerMode === 'select' ? true : 'Shift'"
1226
+ :pan-on-drag="pointerMode === 'hand' ? true : [1]"
1227
+ :pan-on-scroll="true"
1228
+ :pan-on-scroll-mode="PanOnScrollMode.Free"
1229
+ :delete-key-code="['Backspace', 'Delete']"
1230
+ :edges-updatable="true"
1231
+ :min-zoom="0.25"
1232
+ :max-zoom="4"
1233
+ >
1234
+ <Background pattern-color="#cbd5e1" :gap="20" :size="1.2" />
1235
+ </VueFlow>
1236
+ </WorkflowContextMenu>
1237
+
1238
+ <!-- Grid-snapped placement ghost while a palette drag hovers the canvas.
1239
+ Positioned in pane coordinates (flow → screen via the viewport). -->
1240
+ <Transition
1241
+ enter-active-class="transition-all duration-100 ease-out"
1242
+ enter-from-class="opacity-0 scale-90"
1243
+ enter-to-class="opacity-100 scale-100"
1244
+ leave-active-class="transition-opacity duration-100"
1245
+ leave-to-class="opacity-0"
1246
+ >
1247
+ <div
1248
+ v-if="ghostRect"
1249
+ class="pointer-events-none absolute z-10 rounded-lg border-2 border-dashed transition-[left,top] duration-75 ease-out"
1250
+ :class="nodeDrag.dragging.value?.kind === 'trigger' ? 'border-emerald-400/80 bg-emerald-400/[0.08]' : nodeDrag.dragging.value?.kind === 'group' ? 'border-slate-400/80 bg-slate-400/[0.08]' : 'border-violet-400/80 bg-violet-400/[0.08]'"
1251
+ :style="{
1252
+ left: `${ghostRect.left}px`,
1253
+ top: `${ghostRect.top}px`,
1254
+ width: `${ghostRect.width}px`,
1255
+ height: `${ghostRect.height}px`
1256
+ }"
1257
+ >
1258
+ <span
1259
+ class="absolute -top-0.5 left-1/2 -translate-x-1/2 -translate-y-full rounded-md px-1.5 py-0.5 text-xs font-medium text-white shadow-sm"
1260
+ :class="nodeDrag.dragging.value?.kind === 'trigger' ? 'bg-emerald-500' : nodeDrag.dragging.value?.kind === 'group' ? 'bg-slate-500' : 'bg-violet-500'"
1261
+ >
1262
+ Drop to add
1263
+ </span>
1264
+ </div>
1265
+ </Transition>
1266
+
1267
+ <!-- Headless dynamic-output resolvers (PO-143 Phase 3): one per node
1268
+ whose manifest declares a `resolveOutputs` port. They resolve the
1269
+ real ports server-side and feed them into the rendered node without
1270
+ touching the blob. Render nothing. -->
1271
+ <NodeDynamicOutputsResolver
1272
+ v-for="n in dynamicOutputNodes"
1273
+ :key="`dyn-out-${n.id}`"
1274
+ :node-id="n.id"
1275
+ :slug="n.nodeSlug"
1276
+ :version="n.nodeVersion"
1277
+ :config="n.config ?? {}"
1278
+ :static-outputs="findManifest(n.nodeSlug, n.nodeVersion)?.outputs ?? []"
1279
+ @resolved="onResolvedOutputs"
1280
+ />
1281
+
1282
+ <!-- Dock preview — a ghost of the full-height dock slot that lights up
1283
+ while the dragged palette hovers near the left/right canvas edge. -->
1284
+ <div
1285
+ v-if="palette.dockPreview.value"
1286
+ class="pointer-events-none absolute top-3 bottom-16 z-[9] w-80 rounded-xl border-2 border-dashed border-emerald-400/70 bg-emerald-400/[0.08]"
1287
+ :class="palette.dockPreview.value === 'left' ? 'left-3' : 'right-3'"
1288
+ />
340
1289
 
341
- <!-- Top-left action cluster -->
342
- <div class="absolute top-3 left-3 z-10 flex items-center gap-1.5">
343
- <button
344
- type="button"
345
- class="flex items-center gap-1.5 rounded-md border border-black/[0.08] bg-white px-2 py-1 text-sm font-medium text-foreground shadow-sm transition hover:bg-muted/30"
346
- :class="paletteOpen ? 'ring-2 ring-emerald-500/40' : ''"
347
- :title="paletteOpen ? 'Hide palette' : 'Add node'"
348
- @click="paletteOpen = !paletteOpen"
1290
+ <!-- Node palette — dockable left/right, free-floating, or collapsed to
1291
+ a pill. The header (grip + title) drags via pointer events so the
1292
+ node rows keep their HTML5 drag; the ⋮ menu offers the explicit
1293
+ placement actions. -->
1294
+ <Transition
1295
+ enter-active-class="transition duration-150 ease-out"
1296
+ enter-from-class="opacity-0 scale-95"
1297
+ enter-to-class="opacity-100 scale-100"
1298
+ leave-active-class="transition duration-100 ease-in"
1299
+ leave-from-class="opacity-100 scale-100"
1300
+ leave-to-class="opacity-0 scale-95"
1301
+ >
1302
+ <div
1303
+ v-if="palette.collapsed.value"
1304
+ key="palette-pill"
1305
+ class="absolute z-10"
1306
+ :class="palettePillClass"
1307
+ :style="palette.mode.value === 'floating' ? palette.floatingStyle.value : void 0"
1308
+ >
1309
+ <!-- 2-layer frame: outer glass ring + inner white pane (dashboard
1310
+ HeroGreeting pattern) so the pill matches the expanded panel. -->
1311
+ <div class="rounded-lg border border-gray-400/10 outline-1 outline-white/30 backdrop-blur-sm">
1312
+ <button
1313
+ type="button"
1314
+ class="flex h-9 items-center gap-1.5 rounded-lg bg-white px-3 text-sm font-medium transition hover:bg-muted/75 hover:cursor-pointer"
1315
+ title="Expand the node palette"
1316
+ @click="void focusPalette()"
1317
+ >
1318
+ <Plus :size="14" class="text-emerald-600" />
1319
+ Add node
1320
+ </button>
1321
+ </div>
1322
+ </div>
1323
+ <div
1324
+ v-else
1325
+ key="palette-panel"
1326
+ ref="paletteShellRef"
1327
+ class="absolute z-10 flex w-80 flex-col rounded-lg border border-gray-400/10 outline-1 outline-white/30 backdrop-blur-sm"
1328
+ :class="paletteShellClass"
1329
+ :style="palette.mode.value === 'floating' ? palette.floatingStyle.value : void 0"
349
1330
  >
350
- <Plus :size="14" :class="paletteOpen ? 'rotate-45' : ''" class="transition-transform" />
351
- {{ paletteOpen ? "Close" : "Add node" }}
352
- </button>
353
- <button
354
- type="button"
355
- class="flex items-center gap-1.5 rounded-md border border-black/[0.08] bg-white px-2 py-1 text-sm font-medium text-foreground shadow-sm transition hover:bg-muted/30"
356
- title="Tidy up layout"
357
- @click="tidyUp"
1331
+ <div class="flex min-h-0 flex-1 flex-col overflow-hidden border border-white bg-white rounded-lg bg-gradient-to-br from-white via-violet-50/40 to-white relative overflow-hidden">
1332
+ <div
1333
+ class="flex shrink-0 touch-none select-none items-center gap-1 border-b border-black/[0.06] bg-white px-2 py-1.5"
1334
+ :class="palette.dragging.value ? 'cursor-grabbing' : 'cursor-grab'"
1335
+ @pointerdown="palette.startDrag($event, paletteShellRef)"
1336
+ >
1337
+ <GripVertical :size="14" class="shrink-0 text-muted-foreground/60" />
1338
+ <span class="min-w-0 flex-1 truncate text-sm font-medium">Add node</span>
1339
+ <DropdownMenu>
1340
+ <DropdownMenuTrigger as-child>
1341
+ <button
1342
+ type="button"
1343
+ class="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
1344
+ title="Palette placement"
1345
+ >
1346
+ <EllipsisVertical :size="14" />
1347
+ </button>
1348
+ </DropdownMenuTrigger>
1349
+ <DropdownMenuContent align="end" class="w-36 rounded-lg border border-black/[0.06] bg-white p-1 shadow-lg">
1350
+ <DropdownMenuItem class="rounded-md text-sm" @select="palette.dock('left')">Dock left</DropdownMenuItem>
1351
+ <DropdownMenuItem class="rounded-md text-sm" @select="palette.dock('right')">Dock right</DropdownMenuItem>
1352
+ <DropdownMenuItem class="rounded-md text-sm" @select="palette.floatHere(paletteShellRef)">Float here</DropdownMenuItem>
1353
+ <DropdownMenuSeparator class="my-1 h-px bg-black/[0.06]" />
1354
+ <DropdownMenuItem class="rounded-md text-sm" @select="palette.setCollapsed(true)">Collapse</DropdownMenuItem>
1355
+ </DropdownMenuContent>
1356
+ </DropdownMenu>
1357
+ <button
1358
+ type="button"
1359
+ class="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
1360
+ title="Collapse the palette"
1361
+ @click="palette.setCollapsed(true)"
1362
+ >
1363
+ <Minus :size="14" />
1364
+ </button>
1365
+ </div>
1366
+ <NodePalette ref="paletteRef" class="min-h-0 flex-1" @insert="onPaletteInsert" />
1367
+ </div>
1368
+ </div>
1369
+ </Transition>
1370
+
1371
+ <!-- Corner preview — lights up while the dragged minimap hovers a
1372
+ snap zone; release drops it into that corner. -->
1373
+ <div
1374
+ v-if="minimapLayout.dockPreview.value"
1375
+ class="pointer-events-none absolute z-[9] h-[190px] w-[200px] rounded-lg border-2 border-dashed border-emerald-400/70 bg-emerald-400/[0.08]"
1376
+ :class="minimapPreviewClass"
1377
+ />
1378
+
1379
+ <!-- MiniMap — same 2-layer frame as the palette. Lives OUTSIDE the
1380
+ VueFlow tree (the store is provided by this component's
1381
+ useVueFlow), so the shell owns placement: corner-pinned,
1382
+ free-floating via header drag, or collapsed to a pill. -->
1383
+ <Transition
1384
+ enter-active-class="transition duration-150 ease-out"
1385
+ enter-from-class="opacity-0 scale-95"
1386
+ enter-to-class="opacity-100 scale-100"
1387
+ leave-active-class="transition duration-100 ease-in"
1388
+ leave-from-class="opacity-100 scale-100"
1389
+ leave-to-class="opacity-0 scale-95"
1390
+ >
1391
+ <div
1392
+ v-if="minimapLayout.collapsed.value"
1393
+ key="minimap-pill"
1394
+ class="absolute z-10"
1395
+ :class="minimapPlacementClass"
1396
+ :style="minimapLayout.mode.value === 'floating' ? minimapLayout.floatingStyle.value : void 0"
358
1397
  >
359
- <LayoutGrid :size="13" />
360
- Tidy up
361
- </button>
362
- <button
363
- type="button"
364
- class="flex items-center gap-1.5 rounded-md border border-black/[0.08] bg-white px-2 py-1 text-sm font-medium text-foreground shadow-sm transition hover:bg-muted/30"
365
- title="Re-center"
366
- @click="flow.fitView({ padding: 0.2, duration: 400 })"
1398
+ <div class="rounded-lg border border-gray-400/10 outline-1 outline-white/30 backdrop-blur-sm">
1399
+ <button
1400
+ type="button"
1401
+ class="flex h-9 items-center gap-1.5 rounded-lg bg-white px-3 text-sm font-medium transition hover:bg-muted/75 hover:cursor-pointer"
1402
+ title="Expand the minimap"
1403
+ @click="minimapLayout.setCollapsed(false)"
1404
+ >
1405
+ <MapIcon :size="14" class="text-emerald-600" />
1406
+ Map
1407
+ </button>
1408
+ </div>
1409
+ </div>
1410
+ <div
1411
+ v-else
1412
+ key="minimap-panel"
1413
+ ref="minimapShellRef"
1414
+ class="absolute z-10 rounded-lg border border-gray-400/10 outline-1 outline-white/30 backdrop-blur-sm"
1415
+ :class="minimapPlacementClass"
1416
+ :style="minimapLayout.mode.value === 'floating' ? minimapLayout.floatingStyle.value : void 0"
367
1417
  >
368
- <Sparkles :size="13" />
369
- Fit
370
- </button>
1418
+ <div class="flex flex-col overflow-hidden rounded-lg border border-white bg-white bg-gradient-to-br from-white via-violet-50/40 to-white">
1419
+ <div
1420
+ class="flex shrink-0 touch-none select-none items-center gap-1 border-b border-black/[0.06] px-2 py-1"
1421
+ :class="minimapLayout.dragging.value ? 'cursor-grabbing' : 'cursor-grab'"
1422
+ @pointerdown="minimapLayout.startDrag($event, minimapShellRef)"
1423
+ >
1424
+ <GripVertical :size="14" class="shrink-0 text-muted-foreground/60" />
1425
+ <span class="min-w-0 flex-1 truncate text-sm font-medium">Map</span>
1426
+ <DropdownMenu>
1427
+ <DropdownMenuTrigger as-child>
1428
+ <button
1429
+ type="button"
1430
+ class="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
1431
+ title="Minimap placement"
1432
+ >
1433
+ <EllipsisVertical :size="14" />
1434
+ </button>
1435
+ </DropdownMenuTrigger>
1436
+ <DropdownMenuContent align="end" class="w-40 rounded-lg border border-black/[0.06] bg-white p-1 shadow-lg">
1437
+ <DropdownMenuItem class="rounded-md text-sm" @select="minimapLayout.dock('top-left')">Top left</DropdownMenuItem>
1438
+ <DropdownMenuItem class="rounded-md text-sm" @select="minimapLayout.dock('top-right')">Top right</DropdownMenuItem>
1439
+ <DropdownMenuItem class="rounded-md text-sm" @select="minimapLayout.dock('bottom-left')">Bottom left</DropdownMenuItem>
1440
+ <DropdownMenuItem class="rounded-md text-sm" @select="minimapLayout.dock('bottom-right')">Bottom right</DropdownMenuItem>
1441
+ <DropdownMenuItem class="rounded-md text-sm" @select="minimapLayout.floatHere(minimapShellRef)">Float here</DropdownMenuItem>
1442
+ <DropdownMenuSeparator class="my-1 h-px bg-black/[0.06]" />
1443
+ <DropdownMenuItem class="rounded-md text-sm" @select="minimapLayout.setCollapsed(true)">Collapse</DropdownMenuItem>
1444
+ </DropdownMenuContent>
1445
+ </DropdownMenu>
1446
+ <button
1447
+ type="button"
1448
+ class="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
1449
+ title="Collapse the minimap"
1450
+ @click="minimapLayout.setCollapsed(true)"
1451
+ >
1452
+ <Minus :size="14" />
1453
+ </button>
1454
+ </div>
1455
+ <MiniMap
1456
+ class="rvnxx-minimap-embedded"
1457
+ pannable
1458
+ zoomable
1459
+ :node-stroke-width="3"
1460
+ :node-color="(n2) => n2.type === 'trigger' ? '#10b981' : n2.type === 'sticky' ? '#fcd34d' : '#94a3b8'"
1461
+ :mask-color="'rgba(15, 23, 42, 0.06)'"
1462
+ />
1463
+ </div>
1464
+ </div>
1465
+ </Transition>
1466
+
1467
+ <!-- Floating control pane — bottom center. -->
1468
+ <div class="absolute bottom-3 left-1/2 z-10 -translate-x-1/2">
1469
+ <TooltipProvider :delay-duration="250">
1470
+ <div class="rounded-lg border border-gray-400/10 outline-1 outline-white/30 backdrop-blur-sm">
1471
+ <div class="flex items-center gap-0.5 whitespace-nowrap border border-white bg-white rounded-lg bg-gradient-to-br from-white via-violet-50/40 to-white px-2 py-1">
1472
+ <!-- Smart Run: the page decides what "run" means — workflows that
1473
+ consume trigger.payload open the payload dialog, payload-free
1474
+ ones fire immediately. One button either way. -->
1475
+ <IntegrationsEditorTip label="Run workflow" shortcut="mod+enter">
1476
+ <button
1477
+ type="button"
1478
+ class="relative flex h-8 items-center gap-1.5 rounded-lg bg-gradient-to-b from-emerald-500 to-emerald-600 px-3.5 text-sm font-medium text-white transition after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:shadow-[inset_0_1px_0_rgba(255,255,255,0.3),inset_0_-2px_0_rgba(0,0,0,0.25)] hover:from-emerald-600 hover:to-emerald-600 active:translate-y-px disabled:opacity-60"
1479
+ :disabled="runBusy"
1480
+ @click="emit('run')"
1481
+ >
1482
+ <Loader2 v-if="runBusy" :size="14" class="animate-spin" />
1483
+ <Play v-else :size="14" />
1484
+ Run
1485
+ </button>
1486
+ </IntegrationsEditorTip>
1487
+
1488
+ <div class="mx-1 h-5 w-px bg-black/10" />
1489
+
1490
+ <div class="flex items-center gap-0.5 rounded-lg bg-black/[0.05] p-0.5 shadow-[inset_0_1px_2px_rgba(15,23,42,0.12)]">
1491
+ <IntegrationsEditorTip label="Select" shortcut="v">
1492
+ <button
1493
+ type="button"
1494
+ class="flex size-7 items-center justify-center rounded-lg transition"
1495
+ :class="pointerMode === 'select' ? 'bg-gradient-to-b from-white to-gray-50 text-foreground shadow-[0_1px_2px_rgba(15,23,42,0.18),inset_0_1px_0_rgba(255,255,255,0.9)]' : 'text-muted-foreground hover:text-foreground'"
1496
+ @click="setPointerMode('select')"
1497
+ >
1498
+ <MousePointer2 :size="14" />
1499
+ </button>
1500
+ </IntegrationsEditorTip>
1501
+ <IntegrationsEditorTip label="Hand (pan)" shortcut="h">
1502
+ <button
1503
+ type="button"
1504
+ class="flex size-7 items-center justify-center rounded-lg transition"
1505
+ :class="pointerMode === 'hand' ? 'bg-gradient-to-b from-white to-gray-50 text-foreground shadow-[0_1px_2px_rgba(15,23,42,0.18),inset_0_1px_0_rgba(255,255,255,0.9)]' : 'text-muted-foreground hover:text-foreground'"
1506
+ @click="setPointerMode('hand')"
1507
+ >
1508
+ <Hand :size="14" />
1509
+ </button>
1510
+ </IntegrationsEditorTip>
1511
+ </div>
1512
+
1513
+ <IntegrationsEditorTip label="Tidy up" shortcut="shift+alt+t">
1514
+ <button
1515
+ type="button"
1516
+ class="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-foreground transition hover:bg-black/[0.04]"
1517
+ @click="tidyUp"
1518
+ >
1519
+ <LayoutGrid :size="13" />
1520
+ Tidy up
1521
+ </button>
1522
+ </IntegrationsEditorTip>
1523
+ <IntegrationsEditorTip label="Fit view" shortcut="1">
1524
+ <button
1525
+ type="button"
1526
+ class="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-foreground transition hover:bg-black/[0.04]"
1527
+ @click="flow.fitView({ padding: 0.2, duration: 400, maxZoom: 1 })"
1528
+ >
1529
+ <Sparkles :size="13" />
1530
+ Fit
1531
+ </button>
1532
+ </IntegrationsEditorTip>
1533
+ <IntegrationsEditorTip label="Add sticky note" shortcut="shift+s">
1534
+ <button
1535
+ type="button"
1536
+ class="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-foreground transition hover:bg-black/[0.04]"
1537
+ @click="addSticky()"
1538
+ >
1539
+ <StickyNote :size="13" />
1540
+ Note
1541
+ </button>
1542
+ </IntegrationsEditorTip>
1543
+ <IntegrationsEditorTip label="Add group frame — nodes dropped inside join it">
1544
+ <button
1545
+ type="button"
1546
+ class="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-foreground transition hover:bg-black/[0.04]"
1547
+ @click="addGroupFrame"
1548
+ >
1549
+ <GroupIcon :size="13" />
1550
+ Group
1551
+ </button>
1552
+ </IntegrationsEditorTip>
1553
+ <IntegrationsEditorTip label="Workflow variables" shortcut="shift+v">
1554
+ <button
1555
+ type="button"
1556
+ class="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-foreground transition hover:bg-black/[0.04]"
1557
+ @click="variablesOpen = true"
1558
+ >
1559
+ <Braces :size="13" />
1560
+ Variables
1561
+ <span v-if="variableCount" class="rounded-lg bg-muted px-1.5 text-xs tabular-nums text-muted-foreground">{{ variableCount }}</span>
1562
+ </button>
1563
+ </IntegrationsEditorTip>
1564
+ <IntegrationsEditorTip label="Workflow settings" shortcut="shift+alt+s">
1565
+ <button
1566
+ type="button"
1567
+ class="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-foreground transition hover:bg-black/[0.04]"
1568
+ @click="emit('openSettings')"
1569
+ >
1570
+ <Settings2 :size="13" />
1571
+ Settings
1572
+ </button>
1573
+ </IntegrationsEditorTip>
1574
+
1575
+ <div class="mx-1 h-5 w-px bg-black/10" />
1576
+
1577
+ <IntegrationsEditorTip label="Zoom out" shortcut="-">
1578
+ <button
1579
+ type="button"
1580
+ class="flex size-8 items-center justify-center rounded-lg text-foreground transition hover:bg-black/[0.04]"
1581
+ @click="flow.zoomOut({ duration: 150 })"
1582
+ >
1583
+ <ZoomOut :size="14" />
1584
+ </button>
1585
+ </IntegrationsEditorTip>
1586
+ <!-- Tooltip sits on a wrapper span so its as-child merge never
1587
+ touches the dropdown trigger (merging both onto one button
1588
+ swallowed the trigger's pointerdown and the menu never opened). -->
1589
+ <IntegrationsEditorTip label="Zoom level, 0 resets to 100%" shortcut="0">
1590
+ <span class="inline-flex">
1591
+ <DropdownMenu v-model:open="zoomMenuOpen">
1592
+ <DropdownMenuTrigger as-child>
1593
+ <button
1594
+ type="button"
1595
+ class="w-12 rounded-lg py-1 text-center text-sm tabular-nums text-muted-foreground transition hover:bg-black/[0.04] hover:text-foreground"
1596
+ >
1597
+ {{ zoomPct }}
1598
+ </button>
1599
+ </DropdownMenuTrigger>
1600
+ <DropdownMenuContent side="top" align="center" class="w-[4.75rem] min-w-0 rounded-lg border border-black/[0.06] bg-white p-1 shadow-lg">
1601
+ <div class="px-0.5 pb-0.5">
1602
+ <input
1603
+ v-model="zoomInputText"
1604
+ type="text"
1605
+ inputmode="numeric"
1606
+ class="h-6 w-full rounded-md border border-black/[0.08] bg-white px-1 text-center text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-primary/40"
1607
+ placeholder="%"
1608
+ @keydown.stop
1609
+ @keydown.enter.prevent="applyZoomInput"
1610
+ >
1611
+ </div>
1612
+ <DropdownMenuSeparator class="my-1 h-px bg-black/[0.06]" />
1613
+ <DropdownMenuItem
1614
+ v-for="p in ZOOM_PRESETS"
1615
+ :key="p"
1616
+ class="justify-center rounded-md px-1 py-0.5 text-sm tabular-nums"
1617
+ @select="setZoomPct(p)"
1618
+ >
1619
+ {{ p }}%
1620
+ </DropdownMenuItem>
1621
+ </DropdownMenuContent>
1622
+ </DropdownMenu>
1623
+ </span>
1624
+ </IntegrationsEditorTip>
1625
+ <IntegrationsEditorTip label="Zoom in" shortcut="=">
1626
+ <button
1627
+ type="button"
1628
+ class="flex size-8 items-center justify-center rounded-lg text-foreground transition hover:bg-black/[0.04]"
1629
+ @click="flow.zoomIn({ duration: 150 })"
1630
+ >
1631
+ <ZoomIn :size="14" />
1632
+ </button>
1633
+ </IntegrationsEditorTip>
1634
+ </div>
1635
+ </div>
1636
+ </TooltipProvider>
371
1637
  </div>
372
1638
 
373
1639
  <!-- Empty-state -->
@@ -375,45 +1641,59 @@ const edgeTypes = {
375
1641
  v-if="!vfNodes.length"
376
1642
  class="pointer-events-none absolute inset-0 flex items-center justify-center"
377
1643
  >
378
- <div class="pointer-events-auto rounded-lg border border-dashed border-black/[0.12] bg-white/80 px-5 py-4 text-center text-sm text-muted-foreground shadow-sm backdrop-blur-sm">
1644
+ <div class="rounded-lg border border-dashed border-black/[0.12] bg-white/80 px-5 py-4 text-center text-sm text-muted-foreground shadow-sm backdrop-blur-sm">
379
1645
  <div class="font-medium text-foreground mb-1">
380
1646
  Empty canvas
381
1647
  </div>
382
- <p class="mb-3">
383
- Drop a trigger and an action to build your first workflow.
1648
+ <p>
1649
+ Drag a trigger and an action from the panel on the left.
384
1650
  </p>
385
- <button
386
- type="button"
387
- class="inline-flex items-center gap-1 rounded-md bg-emerald-500 px-2 py-1 text-sm font-medium text-white shadow-sm transition hover:bg-emerald-600"
388
- @click="paletteOpen = true"
389
- >
390
- <Plus :size="11" />
391
- Open palette
392
- </button>
393
1651
  </div>
394
1652
  </div>
395
1653
  </div>
396
1654
 
397
- <Transition
398
- enter-active-class="transition-transform duration-200 ease-out"
399
- enter-from-class="translate-x-full"
400
- enter-to-class="translate-x-0"
401
- leave-active-class="transition-transform duration-150 ease-in"
402
- leave-from-class="translate-x-0"
403
- leave-to-class="translate-x-full"
404
- >
405
- <NodeInspector
406
- v-if="selection"
407
- :selection="selection"
408
- :secret-keys="secretKeys"
409
- @update="applyInspectorUpdate"
410
- @close="selectedId = null"
411
- @remove="selectedId && removeBlobNode(selectedId)"
412
- />
413
- </Transition>
1655
+ <!-- Workflow variables lightbox (control-pane entry). -->
1656
+ <WorkflowVariablesDialog
1657
+ v-model:open="variablesOpen"
1658
+ :variables="blob.variables ?? {}"
1659
+ @update:variables="updateVariables"
1660
+ />
1661
+
1662
+ <!-- Node/trigger configuration — a near-fullscreen workbench lightbox
1663
+ (Input pane / Parameters+Settings tabs / Save+Cancel footer). The
1664
+ inspector edits a draft; closing without Save discards it. -->
1665
+ <Dialog :open="!!selection" @update:open="(v) => {
1666
+ if (!v) {
1667
+ selectedId = null;
1668
+ renameIntent = false;
1669
+ }
1670
+ }">
1671
+ <DialogContent class="flex h-[86vh] w-[min(1280px,94vw)] max-w-none flex-col gap-0 overflow-hidden rounded-lg p-0 sm:max-w-none">
1672
+ <NodeInspector
1673
+ v-if="selection"
1674
+ :selection="selection"
1675
+ :secret-keys="secretKeys"
1676
+ :variables="blob.variables"
1677
+ :upstream-outputs="selectedUpstreamOutputs"
1678
+ :autofocus-name="renameIntent"
1679
+ :nodes="blob.nodes"
1680
+ :edges="blob.edges"
1681
+ :triggers="blob.triggers"
1682
+ :dynamic-outputs="dynamicOutputs"
1683
+ :workflow-id="workflow.id"
1684
+ @update="applyInspectorUpdate"
1685
+ @close="selectedId = null"
1686
+ @remove="selectedId && removeBlobNode(selectedId)"
1687
+ @navigate="(id) => {
1688
+ selectedId = id;
1689
+ renameIntent = false;
1690
+ }"
1691
+ />
1692
+ </DialogContent>
1693
+ </Dialog>
414
1694
  </div>
415
1695
  </template>
416
1696
 
417
1697
  <style>
418
- .vue-flow__attribution{display:none}.vue-flow__connection-path{stroke:#94a3b8;stroke-dasharray:4 4;stroke-width:1.75}.vue-flow__nodesselection-rect,.vue-flow__selection{background:rgba(16,185,129,.06);border:1px dashed rgba(16,185,129,.45)}.vue-flow__handle{z-index:5}.vue-flow__edge.selected .vue-flow__edge-path{filter:drop-shadow(0 0 4px rgba(99,102,241,.5))}.vue-flow__controls{border:1px solid rgba(15,23,42,.08);border-radius:8px;box-shadow:0 1px 2px rgba(15,23,42,.06);overflow:hidden}.vue-flow__controls-button{background:#fff;border-bottom:1px solid rgba(15,23,42,.04);height:28px;width:28px}.vue-flow__controls-button svg{fill:#475569}.vue-flow__minimap{background:hsla(0,0%,100%,.9)!important;border:1px solid rgba(15,23,42,.08);border-radius:8px;box-shadow:0 1px 2px rgba(15,23,42,.06);overflow:hidden}
1698
+ .vue-flow__attribution{display:none}.vue-flow__connection-path{stroke:#94a3b8;stroke-dasharray:4 4;stroke-width:1.75}.vue-flow__nodesselection-rect,.vue-flow__selection{background:rgba(16,185,129,.06);border:1px dashed rgba(16,185,129,.45)}.vue-flow__handle{z-index:5}.vue-flow__edge.selected .vue-flow__edge-path{filter:drop-shadow(0 0 4px rgba(99,102,241,.5))}.vue-flow__node.node-pop-in{animation:rvnxx-node-pop .38s cubic-bezier(.2,.85,.3,1.25) both}@keyframes rvnxx-node-pop{0%{opacity:0;scale:.6}60%{opacity:1;scale:1.06}to{opacity:1;scale:1}}.vue-flow__node-toolbar{z-index:1200!important}.canvas-drop-active{box-shadow:inset 0 0 0 2px rgba(139,92,246,.35)}.rvnxx-minimap-embedded.vue-flow__minimap{background:transparent!important;border:none;border-radius:0;box-shadow:none;margin:0;position:static}
419
1699
  </style>