Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const TaskNodeCard = () => {
UpdateOverlayMessage["data"] | undefined
>();
const [highlightedState, setHighlighted] = useState(false);
const [spotlightState, setSpotlight] = useState(false);

const [expandedInputs, setExpandedInputs] = useState(false);
const [expandedOutputs, setExpandedOutputs] = useState(false);
Expand Down Expand Up @@ -112,9 +113,19 @@ const TaskNodeCard = () => {
...message.data,
});
break;
case "spotlight":
setSpotlight(true);
break;
}
}, []);

// The spotlight is a one-shot reveal animation; clear it once it has played.
useEffect(() => {
if (!spotlightState) return;
const timeout = setTimeout(() => setSpotlight(false), 1300);
return () => clearTimeout(timeout);
}, [spotlightState]);

useEffect(() => {
if (!taskSpec) return;
return registerNode({
Expand Down Expand Up @@ -200,6 +211,7 @@ const TaskNodeCard = () => {
isConnectedToSelectedEdge &&
"border-edge-selected! ring-2 ring-edge-selected/30",
isSubgraphNode && "cursor-pointer",
spotlightState && "animate-spotlight",
)}
style={{
width: dimensions.w + "px",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";

import { computePlacementPosition } from "./computePlacementPosition";
import { type Bounds, rectsOverlap } from "./geometry";

const rect = (x: number, y: number, width = 300, height = 100): Bounds => ({
x,
y,
width,
height,
});

describe("rectsOverlap", () => {
it("detects overlap and separation", () => {
expect(rectsOverlap(rect(0, 0), rect(50, 50))).toBe(true);
expect(rectsOverlap(rect(0, 0), rect(0, 140))).toBe(false); // gap between
expect(rectsOverlap(rect(0, 0), rect(400, 0))).toBe(false); // side by side
});
});

describe("computePlacementPosition", () => {
const anchor = rect(0, 0, 300, 100);

it("places directly below in the same column when clear", () => {
const pos = computePlacementPosition(anchor, [], { prefer: "below" });
expect(pos).toEqual({ x: 0, y: 140 }); // anchorBottom(100) + gap(40)
});

it("pushes past a stacked node below until the slot is clear", () => {
const below = rect(0, 120, 300, 100); // occupies y 120..220
const above = rect(0, -160, 300, 100); // occupies y -160..-60 (forces below)
const pos = computePlacementPosition(anchor, [below, above], {
prefer: "below",
});
// below candidate 140 overlaps -> pushed to 220 + gap(40) = 260
expect(pos).toEqual({ x: 0, y: 260 });
});

it("falls back above when below is far and above is closer", () => {
const tallBelow = rect(0, 120, 300, 500); // occupies y 120..620
const pos = computePlacementPosition(anchor, [tallBelow], {
prefer: "below",
});
// below would be 660 (far); above is clear at -140 (closer) -> chosen
expect(pos).toEqual({ x: 0, y: -140 });
});

it("keeps the new node in the anchor's column (same x)", () => {
const shifted = rect(500, 0, 300, 100);
const pos = computePlacementPosition(shifted, [], { prefer: "below" });
expect(pos.x).toBe(500);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { XYPosition } from "@xyflow/react";

import { type Bounds, rectsOverlap } from "./geometry";

const DEFAULT_GAP = 40;

export interface ComputePlacementOptions {
/** Preferred direction from the anchor. Defaults to "below". */
prefer?: "below" | "above";
/** Vertical gap to leave between nodes. Defaults to 40. */
gap?: number;
}

/**
* Computes a position for a new node placed directly above or below an anchor
* node, in the same column, at a Y that does not overlap any of `otherRects`.
*
* Starting just past the anchor in the preferred direction, it walks away from
* the anchor past any overlapping node until the slot is clear, then does the
* same in the opposite direction, and returns whichever clear slot is closer
* to the anchor. Distance is intentionally unbounded — the caller is expected
* to animate the viewport to reveal the result.
*/
export function computePlacementPosition(
anchor: Bounds,
otherRects: Bounds[],
{ prefer = "below", gap = DEFAULT_GAP }: ComputePlacementOptions = {},
): XYPosition {
const width = anchor.width;
const height = anchor.height;
const x = anchor.x;

const clearBelow = () => {
let y = anchor.y + anchor.height + gap;
// Walk down past any node the candidate rect overlaps.
// Re-checks from scratch each pass so stacked nodes are all cleared.
let moved = true;
while (moved) {
moved = false;
for (const other of otherRects) {
if (rectsOverlap({ x, y, width, height }, other)) {
y = other.y + other.height + gap;
moved = true;
}
}
}
return y;
};

const clearAbove = () => {
let y = anchor.y - height - gap;
let moved = true;
while (moved) {
moved = false;
for (const other of otherRects) {
if (rectsOverlap({ x, y, width, height }, other)) {
y = other.y - height - gap;
moved = true;
}
}
}
return y;
};

const belowY = clearBelow();
const aboveY = clearAbove();

// Distance of each clear slot from the anchor's nearest edge.
const belowDist = belowY - (anchor.y + anchor.height);
const aboveDist = anchor.y - (aboveY + height);

const preferBelow =
prefer === "below" ? belowDist <= aboveDist : belowDist < aboveDist;

return { x, y: preferBelow ? belowY : aboveY };
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import type { Node, XYPosition } from "@xyflow/react";

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

/** Axis-aligned bounding-box overlap test for two rects. */
export const rectsOverlap = (a: Bounds, b: Bounds): boolean =>
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y;

export const isPositionInNode = (node: Node, position: XYPosition) => {
const nodeRect = {
x: node.position.x,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,17 @@ type ClearMessage = {
type: "clear";
};

/** A brief, self-clearing "spotlight" pulse to reveal a node (e.g. one that
* was just placed on the canvas). Unlike "highlight", it animates out on its
* own and does not need a matching "clear". */
type SpotlightMessage = {
type: "spotlight";
};

export type NotifyMessage =
| HighlightMessage
| ClearMessage
| SpotlightMessage
| UpdateOverlayMessage;

interface NodesOverlayContextType {
Expand Down
129 changes: 119 additions & 10 deletions src/components/shared/TaskDetails/Actions/EditComponentButton.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,33 @@
import { useReactFlow } from "@xyflow/react";
import { useState } from "react";

import addTask from "@/components/shared/ReactFlow/FlowCanvas/utils/addTask";
import { computePlacementPosition } from "@/components/shared/ReactFlow/FlowCanvas/utils/computePlacementPosition";
import type { Bounds } from "@/components/shared/ReactFlow/FlowCanvas/utils/geometry";
import { replaceTaskComponentRef } from "@/components/shared/ReactFlow/FlowCanvas/utils/replaceTaskComponentRef";
import { useNodesOverlay } from "@/components/shared/ReactFlow/NodesOverlay/NodesOverlayProvider";
import useToastNotification from "@/hooks/useToastNotification";
import { useComponentSpec } from "@/providers/ComponentSpecProvider";
import type { HydratedComponentReference } from "@/utils/componentSpec";
import { extractPositionFromAnnotations } from "@/utils/annotations";
import {
type ComponentSpec,
type HydratedComponentReference,
isGraphImplementation,
type TaskSpec,
} from "@/utils/componentSpec";
import { diffComponentIO } from "@/utils/componentSpecDiff";
import { DEFAULT_NODE_DIMENSIONS } from "@/utils/constants";
import { taskIdToNodeId } from "@/utils/nodes/nodeIdUtils";
import { tracking } from "@/utils/tracking";

import { ActionButton } from "../../Buttons/ActionButton";
import { ComponentEditorDialog } from "../../ComponentEditor/ComponentEditorDialog";
import type { SaveAction } from "../../ComponentEditor/saveAction";
import { SaveActionsView } from "../../ComponentEditor/SaveActionsView";

// Fallback height for nodes that have not been measured yet (e.g. just added).
const ESTIMATED_NODE_HEIGHT = 120;

interface EditComponentButtonProps {
componentRef: HydratedComponentReference;
taskId?: string;
Expand All @@ -24,18 +40,12 @@ export const EditComponentButton = ({
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const notify = useToastNotification();
const { currentGraphSpec, updateGraphSpec } = useComponentSpec();
const { getNodes } = useReactFlow();
const { fitNodeIntoView, selectNode, notifyNode } = useNodesOverlay();

const editedTask = taskId ? currentGraphSpec?.tasks[taskId] : undefined;

const handleComponentSaved = (
hydratedComponent: HydratedComponentReference,
action: SaveAction,
) => {
if (action !== "update") {
// "place" arrives once placement ships; nothing else applies in place.
return;
}

const updateInPlace = (hydratedComponent: HydratedComponentReference) => {
if (!taskId || !currentGraphSpec?.tasks[taskId]) {
notify(
"Could not update the component: the edited task was not found.",
Expand Down Expand Up @@ -63,6 +73,104 @@ export const EditComponentButton = ({
}
};

const placeAsNewTask = (hydratedComponent: HydratedComponentReference) => {
if (!taskId || !editedTask || !currentGraphSpec) {
notify(
"Could not place a new task: the edited task was not found.",
"error",
);
return;
}

// Anchor on the edited task; avoid overlapping any existing node.
const nodes = getNodes();
const toRect = (
x: number,
y: number,
width?: number,
height?: number,
): Bounds => ({
x,
y,
width: width ?? DEFAULT_NODE_DIMENSIONS.w,
height: height ?? ESTIMATED_NODE_HEIGHT,
});

const anchorNodeId = taskIdToNodeId(taskId);
const anchorNode = nodes.find((node) => node.id === anchorNodeId);
const anchorPosition = extractPositionFromAnnotations(
editedTask.annotations,
);
const anchorRect = anchorNode
? toRect(
anchorNode.position.x,
anchorNode.position.y,
anchorNode.measured?.width,
anchorNode.measured?.height,
)
: toRect(anchorPosition.x, anchorPosition.y);

const otherRects = nodes
.filter((node) => node.id !== anchorNodeId)
.map((node) =>
toRect(
node.position.x,
node.position.y,
node.measured?.width,
node.measured?.height,
),
);

const position = computePlacementPosition(anchorRect, otherRects, {
prefer: "below",
});

const newTaskSpec: TaskSpec = {
annotations: {},
componentRef: hydratedComponent,
};

// Add to the current (sub)graph, then write it back through the provider.
const wrapperSpec: ComponentSpec = {
implementation: { graph: currentGraphSpec },
};
const { spec: updatedWrapper, taskId: newTaskId } = addTask(
"task",
newTaskSpec,
position,
wrapperSpec,
);

if (!isGraphImplementation(updatedWrapper.implementation) || !newTaskId) {
notify("Could not place a new task.", "error");
return;
}

updateGraphSpec(updatedWrapper.implementation.graph);
notify("Task added", "success");

// The new node mounts asynchronously; wait for it, then reveal + spotlight.
const newNodeId = taskIdToNodeId(newTaskId);
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
await fitNodeIntoView(newNodeId);
selectNode(newNodeId);
notifyNode(newNodeId, { type: "spotlight" });
});
});
};

const handleComponentSaved = (
hydratedComponent: HydratedComponentReference,
action: SaveAction,
) => {
if (action === "update") {
updateInPlace(hydratedComponent);
} else if (action === "place") {
placeAsNewTask(hydratedComponent);
}
};

return (
<>
<ActionButton
Expand All @@ -88,6 +196,7 @@ export const EditComponentButton = ({
taskName={componentRef.name}
inputDiff={inputDiff}
outputDiff={outputDiff}
allowPlace
onChoose={onChoose}
/>
);
Expand Down
Loading
Loading