diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeCard.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeCard.tsx index 3db48e585..70f65aa8d 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeCard.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeCard.tsx @@ -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); @@ -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({ @@ -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", diff --git a/src/components/shared/ReactFlow/FlowCanvas/utils/computePlacementPosition.test.ts b/src/components/shared/ReactFlow/FlowCanvas/utils/computePlacementPosition.test.ts new file mode 100644 index 000000000..4431a2268 --- /dev/null +++ b/src/components/shared/ReactFlow/FlowCanvas/utils/computePlacementPosition.test.ts @@ -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); + }); +}); diff --git a/src/components/shared/ReactFlow/FlowCanvas/utils/computePlacementPosition.ts b/src/components/shared/ReactFlow/FlowCanvas/utils/computePlacementPosition.ts new file mode 100644 index 000000000..5de4ee045 --- /dev/null +++ b/src/components/shared/ReactFlow/FlowCanvas/utils/computePlacementPosition.ts @@ -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 }; +} diff --git a/src/components/shared/ReactFlow/FlowCanvas/utils/geometry.ts b/src/components/shared/ReactFlow/FlowCanvas/utils/geometry.ts index 5b6b364d6..e55ffeed3 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/utils/geometry.ts +++ b/src/components/shared/ReactFlow/FlowCanvas/utils/geometry.ts @@ -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, diff --git a/src/components/shared/ReactFlow/NodesOverlay/NodesOverlayProvider.tsx b/src/components/shared/ReactFlow/NodesOverlay/NodesOverlayProvider.tsx index f826c45e1..4e97e1c8d 100644 --- a/src/components/shared/ReactFlow/NodesOverlay/NodesOverlayProvider.tsx +++ b/src/components/shared/ReactFlow/NodesOverlay/NodesOverlayProvider.tsx @@ -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 { diff --git a/src/components/shared/TaskDetails/Actions/EditComponentButton.tsx b/src/components/shared/TaskDetails/Actions/EditComponentButton.tsx index f4ab7cdd6..9729918a8 100644 --- a/src/components/shared/TaskDetails/Actions/EditComponentButton.tsx +++ b/src/components/shared/TaskDetails/Actions/EditComponentButton.tsx @@ -1,10 +1,23 @@ +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"; @@ -12,6 +25,9 @@ import { ComponentEditorDialog } from "../../ComponentEditor/ComponentEditorDial 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; @@ -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.", @@ -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 ( <> ); diff --git a/src/styles/global.css b/src/styles/global.css index cece2b4cc..f1d7944b2 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -151,6 +151,9 @@ code { /* Custom animations */ --animate-revert-copied: revert-copied 0.5s ease-in-out forwards; --animate-highlight-fade: highlight-fade 2s ease-out forwards; + /* Brief spotlight that pulses in and fades out, used to reveal a node that + was just placed on the canvas (e.g. "Place as a new task"). */ + --animate-spotlight: spotlight 1.2s ease-out forwards; @keyframes revert-copied { 0%, @@ -178,6 +181,25 @@ code { box-shadow: none; } } + + @keyframes spotlight { + 0% { + box-shadow: 0 0 0 0 oklch(0.62 0.19 260 / 0); + transform: scale(1); + } + 25% { + box-shadow: 0 0 0 6px oklch(0.62 0.19 260 / 0.55); + transform: scale(1.02); + } + 60% { + box-shadow: 0 0 0 4px oklch(0.62 0.19 260 / 0.35); + transform: scale(1); + } + 100% { + box-shadow: 0 0 0 0 oklch(0.62 0.19 260 / 0); + transform: scale(1); + } + } } @layer base {