Skip to content

Commit 9bba106

Browse files
committed
feat: "Place as a new task" with intelligent placement (legacy editor)
Adds the third Save action to the legacy editor's choose-action modal: instead of replacing the selected task, create a NEW task from the edited definition, dropped directly above/below the selected task without overlapping any node, then animate the canvas to it and play a brief spotlight to show where it landed. - `computePlacementPosition` (+ `rectsOverlap` in geometry): pure geometry that finds a non-overlapping slot in the anchor's column, walking past stacked nodes in the preferred direction and falling back to whichever of above/below is closer. Distance is unbounded — the viewport animation handles far placements. Unmeasured nodes fall back to `DEFAULT_NODE_DIMENSIONS` + an estimated height. - `@keyframes spotlight` (+ `--animate-spotlight`) in global.css: a one-shot pulse that fades in and out (shared with the v2 editor). - `NodesOverlayProvider`/`TaskNodeCard`: a new self-clearing `"spotlight"` notify variant that applies `animate-spotlight` to the node for ~1.2s. - `EditComponentButton`: `allowPlace` is enabled; on `"place"` it builds a task from the hydrated component (component defaults only), computes a position from live node rects (`useReactFlow().getNodes()`), adds it to the current (sub)graph via the existing `addTask` + `updateGraphSpec`, then (after a double-rAF for mount) calls `fitNodeIntoView`, selects, and spotlights it. Tests: `computePlacementPosition`/`rectsOverlap` (clear-below, push-past-stack, fallback-above, same-column).
1 parent 5d5fe22 commit 9bba106

7 files changed

Lines changed: 299 additions & 12 deletions

File tree

src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeCard.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const TaskNodeCard = () => {
5959
UpdateOverlayMessage["data"] | undefined
6060
>();
6161
const [highlightedState, setHighlighted] = useState(false);
62+
const [spotlightState, setSpotlight] = useState(false);
6263

6364
const [expandedInputs, setExpandedInputs] = useState(false);
6465
const [expandedOutputs, setExpandedOutputs] = useState(false);
@@ -112,9 +113,19 @@ const TaskNodeCard = () => {
112113
...message.data,
113114
});
114115
break;
116+
case "spotlight":
117+
setSpotlight(true);
118+
break;
115119
}
116120
}, []);
117121

122+
// The spotlight is a one-shot reveal animation; clear it once it has played.
123+
useEffect(() => {
124+
if (!spotlightState) return;
125+
const timeout = setTimeout(() => setSpotlight(false), 1300);
126+
return () => clearTimeout(timeout);
127+
}, [spotlightState]);
128+
118129
useEffect(() => {
119130
if (!taskSpec) return;
120131
return registerNode({
@@ -200,6 +211,7 @@ const TaskNodeCard = () => {
200211
isConnectedToSelectedEdge &&
201212
"border-edge-selected! ring-2 ring-edge-selected/30",
202213
isSubgraphNode && "cursor-pointer",
214+
spotlightState && "animate-spotlight",
203215
)}
204216
style={{
205217
width: dimensions.w + "px",
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { computePlacementPosition } from "./computePlacementPosition";
4+
import { type Bounds, rectsOverlap } from "./geometry";
5+
6+
const rect = (x: number, y: number, width = 300, height = 100): Bounds => ({
7+
x,
8+
y,
9+
width,
10+
height,
11+
});
12+
13+
describe("rectsOverlap", () => {
14+
it("detects overlap and separation", () => {
15+
expect(rectsOverlap(rect(0, 0), rect(50, 50))).toBe(true);
16+
expect(rectsOverlap(rect(0, 0), rect(0, 140))).toBe(false); // gap between
17+
expect(rectsOverlap(rect(0, 0), rect(400, 0))).toBe(false); // side by side
18+
});
19+
});
20+
21+
describe("computePlacementPosition", () => {
22+
const anchor = rect(0, 0, 300, 100);
23+
24+
it("places directly below in the same column when clear", () => {
25+
const pos = computePlacementPosition(anchor, [], { prefer: "below" });
26+
expect(pos).toEqual({ x: 0, y: 140 }); // anchorBottom(100) + gap(40)
27+
});
28+
29+
it("pushes past a stacked node below until the slot is clear", () => {
30+
const below = rect(0, 120, 300, 100); // occupies y 120..220
31+
const above = rect(0, -160, 300, 100); // occupies y -160..-60 (forces below)
32+
const pos = computePlacementPosition(anchor, [below, above], {
33+
prefer: "below",
34+
});
35+
// below candidate 140 overlaps -> pushed to 220 + gap(40) = 260
36+
expect(pos).toEqual({ x: 0, y: 260 });
37+
});
38+
39+
it("falls back above when below is far and above is closer", () => {
40+
const tallBelow = rect(0, 120, 300, 500); // occupies y 120..620
41+
const pos = computePlacementPosition(anchor, [tallBelow], {
42+
prefer: "below",
43+
});
44+
// below would be 660 (far); above is clear at -140 (closer) -> chosen
45+
expect(pos).toEqual({ x: 0, y: -140 });
46+
});
47+
48+
it("keeps the new node in the anchor's column (same x)", () => {
49+
const shifted = rect(500, 0, 300, 100);
50+
const pos = computePlacementPosition(shifted, [], { prefer: "below" });
51+
expect(pos.x).toBe(500);
52+
});
53+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { XYPosition } from "@xyflow/react";
2+
3+
import { type Bounds, rectsOverlap } from "./geometry";
4+
5+
const DEFAULT_GAP = 40;
6+
7+
export interface ComputePlacementOptions {
8+
/** Preferred direction from the anchor. Defaults to "below". */
9+
prefer?: "below" | "above";
10+
/** Vertical gap to leave between nodes. Defaults to 40. */
11+
gap?: number;
12+
}
13+
14+
/**
15+
* Computes a position for a new node placed directly above or below an anchor
16+
* node, in the same column, at a Y that does not overlap any of `otherRects`.
17+
*
18+
* Starting just past the anchor in the preferred direction, it walks away from
19+
* the anchor past any overlapping node until the slot is clear, then does the
20+
* same in the opposite direction, and returns whichever clear slot is closer
21+
* to the anchor. Distance is intentionally unbounded — the caller is expected
22+
* to animate the viewport to reveal the result.
23+
*/
24+
export function computePlacementPosition(
25+
anchor: Bounds,
26+
otherRects: Bounds[],
27+
{ prefer = "below", gap = DEFAULT_GAP }: ComputePlacementOptions = {},
28+
): XYPosition {
29+
const width = anchor.width;
30+
const height = anchor.height;
31+
const x = anchor.x;
32+
33+
const clearBelow = () => {
34+
let y = anchor.y + anchor.height + gap;
35+
// Walk down past any node the candidate rect overlaps.
36+
// Re-checks from scratch each pass so stacked nodes are all cleared.
37+
let moved = true;
38+
while (moved) {
39+
moved = false;
40+
for (const other of otherRects) {
41+
if (rectsOverlap({ x, y, width, height }, other)) {
42+
y = other.y + other.height + gap;
43+
moved = true;
44+
}
45+
}
46+
}
47+
return y;
48+
};
49+
50+
const clearAbove = () => {
51+
let y = anchor.y - height - gap;
52+
let moved = true;
53+
while (moved) {
54+
moved = false;
55+
for (const other of otherRects) {
56+
if (rectsOverlap({ x, y, width, height }, other)) {
57+
y = other.y - height - gap;
58+
moved = true;
59+
}
60+
}
61+
}
62+
return y;
63+
};
64+
65+
const belowY = clearBelow();
66+
const aboveY = clearAbove();
67+
68+
// Distance of each clear slot from the anchor's nearest edge.
69+
const belowDist = belowY - (anchor.y + anchor.height);
70+
const aboveDist = anchor.y - (aboveY + height);
71+
72+
const preferBelow =
73+
prefer === "below" ? belowDist <= aboveDist : belowDist < aboveDist;
74+
75+
return { x, y: preferBelow ? belowY : aboveY };
76+
}

src/components/shared/ReactFlow/FlowCanvas/utils/geometry.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ import type { Node, XYPosition } from "@xyflow/react";
22

33
export type Bounds = { x: number; y: number; width: number; height: number };
44

5+
/** Axis-aligned bounding-box overlap test for two rects. */
6+
export const rectsOverlap = (a: Bounds, b: Bounds): boolean =>
7+
a.x < b.x + b.width &&
8+
a.x + a.width > b.x &&
9+
a.y < b.y + b.height &&
10+
a.y + a.height > b.y;
11+
512
export const isPositionInNode = (node: Node, position: XYPosition) => {
613
const nodeRect = {
714
x: node.position.x,

src/components/shared/ReactFlow/NodesOverlay/NodesOverlayProvider.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,17 @@ type ClearMessage = {
3535
type: "clear";
3636
};
3737

38+
/** A brief, self-clearing "spotlight" pulse to reveal a node (e.g. one that
39+
* was just placed on the canvas). Unlike "highlight", it animates out on its
40+
* own and does not need a matching "clear". */
41+
type SpotlightMessage = {
42+
type: "spotlight";
43+
};
44+
3845
export type NotifyMessage =
3946
| HighlightMessage
4047
| ClearMessage
48+
| SpotlightMessage
4149
| UpdateOverlayMessage;
4250

4351
interface NodesOverlayContextType {

src/components/shared/TaskDetails/Actions/EditComponentButton.tsx

Lines changed: 121 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
1+
import { useReactFlow } from "@xyflow/react";
12
import { useState } from "react";
23

4+
import addTask from "@/components/shared/ReactFlow/FlowCanvas/utils/addTask";
5+
import { computePlacementPosition } from "@/components/shared/ReactFlow/FlowCanvas/utils/computePlacementPosition";
6+
import type { Bounds } from "@/components/shared/ReactFlow/FlowCanvas/utils/geometry";
37
import { replaceTaskComponentRef } from "@/components/shared/ReactFlow/FlowCanvas/utils/replaceTaskComponentRef";
8+
import { useNodesOverlay } from "@/components/shared/ReactFlow/NodesOverlay/NodesOverlayProvider";
49
import useToastNotification from "@/hooks/useToastNotification";
510
import { useComponentSpec } from "@/providers/ComponentSpecProvider";
6-
import type { HydratedComponentReference } from "@/utils/componentSpec";
11+
import { extractPositionFromAnnotations } from "@/utils/annotations";
12+
import {
13+
type ComponentSpec,
14+
type HydratedComponentReference,
15+
isGraphImplementation,
16+
type TaskSpec,
17+
} from "@/utils/componentSpec";
718
import { diffComponentIO } from "@/utils/componentSpecDiff";
19+
import { DEFAULT_NODE_DIMENSIONS } from "@/utils/constants";
20+
import { taskIdToNodeId } from "@/utils/nodes/nodeIdUtils";
821
import { tracking } from "@/utils/tracking";
922

1023
import { ActionButton } from "../../Buttons/ActionButton";
1124
import { ComponentEditorDialog } from "../../ComponentEditor/ComponentEditorDialog";
1225
import type { SaveAction } from "../../ComponentEditor/saveAction";
1326
import { useChooseSaveAction } from "../../ComponentEditor/useChooseSaveAction";
1427

28+
// Fallback height for nodes that have not been measured yet (e.g. just added).
29+
const ESTIMATED_NODE_HEIGHT = 120;
30+
1531
interface EditComponentButtonProps {
1632
componentRef: HydratedComponentReference;
1733
taskId?: string;
@@ -25,33 +41,28 @@ export const EditComponentButton = ({
2541
const notify = useToastNotification();
2642
const { currentGraphSpec, updateGraphSpec } = useComponentSpec();
2743
const { chooseSaveAction, chooseSaveActionDialog } = useChooseSaveAction();
44+
const { getNodes } = useReactFlow();
45+
const { fitNodeIntoView, selectNode, notifyNode } = useNodesOverlay();
2846

29-
const taskSpec = taskId ? currentGraphSpec?.tasks[taskId] : undefined;
47+
const editedTask = taskId ? currentGraphSpec?.tasks[taskId] : undefined;
3048

3149
const resolveSaveAction = async (
3250
hydratedComponent: HydratedComponentReference,
3351
): Promise<SaveAction> => {
3452
const { inputDiff, outputDiff } = diffComponentIO(
35-
taskSpec?.componentRef.spec,
53+
editedTask?.componentRef.spec,
3654
hydratedComponent.spec,
3755
);
3856

3957
return chooseSaveAction({
4058
taskName: componentRef.name,
4159
inputDiff,
4260
outputDiff,
61+
allowPlace: true,
4362
});
4463
};
4564

46-
const handleComponentSaved = (
47-
hydratedComponent: HydratedComponentReference,
48-
action: SaveAction,
49-
) => {
50-
if (action !== "update") {
51-
// "place" arrives once placement ships; nothing else applies in place.
52-
return;
53-
}
54-
65+
const updateInPlace = (hydratedComponent: HydratedComponentReference) => {
5566
if (!taskId || !currentGraphSpec?.tasks[taskId]) {
5667
notify(
5768
"Could not update the component: the edited task was not found.",
@@ -79,6 +90,104 @@ export const EditComponentButton = ({
7990
}
8091
};
8192

93+
const placeAsNewTask = (hydratedComponent: HydratedComponentReference) => {
94+
if (!taskId || !editedTask || !currentGraphSpec) {
95+
notify(
96+
"Could not place a new task: the edited task was not found.",
97+
"error",
98+
);
99+
return;
100+
}
101+
102+
// Anchor on the edited task; avoid overlapping any existing node.
103+
const nodes = getNodes();
104+
const toRect = (
105+
x: number,
106+
y: number,
107+
width?: number,
108+
height?: number,
109+
): Bounds => ({
110+
x,
111+
y,
112+
width: width ?? DEFAULT_NODE_DIMENSIONS.w,
113+
height: height ?? ESTIMATED_NODE_HEIGHT,
114+
});
115+
116+
const anchorNodeId = taskIdToNodeId(taskId);
117+
const anchorNode = nodes.find((node) => node.id === anchorNodeId);
118+
const anchorPosition = extractPositionFromAnnotations(
119+
editedTask.annotations,
120+
);
121+
const anchorRect = anchorNode
122+
? toRect(
123+
anchorNode.position.x,
124+
anchorNode.position.y,
125+
anchorNode.measured?.width,
126+
anchorNode.measured?.height,
127+
)
128+
: toRect(anchorPosition.x, anchorPosition.y);
129+
130+
const otherRects = nodes
131+
.filter((node) => node.id !== anchorNodeId)
132+
.map((node) =>
133+
toRect(
134+
node.position.x,
135+
node.position.y,
136+
node.measured?.width,
137+
node.measured?.height,
138+
),
139+
);
140+
141+
const position = computePlacementPosition(anchorRect, otherRects, {
142+
prefer: "below",
143+
});
144+
145+
const newTaskSpec: TaskSpec = {
146+
annotations: {},
147+
componentRef: hydratedComponent,
148+
};
149+
150+
// Add to the current (sub)graph, then write it back through the provider.
151+
const wrapperSpec: ComponentSpec = {
152+
implementation: { graph: currentGraphSpec },
153+
};
154+
const { spec: updatedWrapper, taskId: newTaskId } = addTask(
155+
"task",
156+
newTaskSpec,
157+
position,
158+
wrapperSpec,
159+
);
160+
161+
if (!isGraphImplementation(updatedWrapper.implementation) || !newTaskId) {
162+
notify("Could not place a new task.", "error");
163+
return;
164+
}
165+
166+
updateGraphSpec(updatedWrapper.implementation.graph);
167+
notify("Task added", "success");
168+
169+
// The new node mounts asynchronously; wait for it, then reveal + spotlight.
170+
const newNodeId = taskIdToNodeId(newTaskId);
171+
requestAnimationFrame(() => {
172+
requestAnimationFrame(async () => {
173+
await fitNodeIntoView(newNodeId);
174+
selectNode(newNodeId);
175+
notifyNode(newNodeId, { type: "spotlight" });
176+
});
177+
});
178+
};
179+
180+
const handleComponentSaved = (
181+
hydratedComponent: HydratedComponentReference,
182+
action: SaveAction,
183+
) => {
184+
if (action === "update") {
185+
updateInPlace(hydratedComponent);
186+
} else if (action === "place") {
187+
placeAsNewTask(hydratedComponent);
188+
}
189+
};
190+
82191
return (
83192
<>
84193
<ActionButton

0 commit comments

Comments
 (0)