diff --git a/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/TaskDetails.tsx b/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/TaskDetails.tsx index f6cacf383..3e3133acb 100644 --- a/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/TaskDetails.tsx +++ b/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/TaskDetails.tsx @@ -3,6 +3,8 @@ import { useEffect, useRef, useState } from "react"; import type { SaveAction } from "@/components/shared/ComponentEditor/saveAction"; import { SaveActionsView } from "@/components/shared/ComponentEditor/SaveActionsView"; +import { computePlacementPosition } from "@/components/shared/ReactFlow/FlowCanvas/utils/computePlacementPosition"; +import type { Bounds } from "@/components/shared/ReactFlow/FlowCanvas/utils/geometry"; import { StackingControls } from "@/components/shared/ReactFlow/FlowControls/StackingControls"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; @@ -18,9 +20,14 @@ import { useTaskActions } from "@/routes/v2/pages/Editor/store/actions/useTaskAc import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext"; import { useSpec } from "@/routes/v2/shared/providers/SpecContext"; import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; -import { SYSTEM_ANNOTATIONS, ZINDEX_ANNOTATION } from "@/utils/annotations"; +import { + EDITOR_POSITION_ANNOTATION, + SYSTEM_ANNOTATIONS, + ZINDEX_ANNOTATION, +} from "@/utils/annotations"; import type { HydratedComponentReference } from "@/utils/componentSpec"; import { diffComponentIO } from "@/utils/componentSpecDiff"; +import { DEFAULT_NODE_DIMENSIONS } from "@/utils/constants"; import { tracking } from "@/utils/tracking"; import { getTaskYamlText } from "./components/actions/getTaskYamlText"; @@ -42,7 +49,7 @@ export const TaskDetails = observer(function TaskDetails({ const { track } = useAnalytics(); const { editor } = useSharedStores(); const { undo } = useEditorSession(); - const { renameTask, replaceTask } = useTaskActions(); + const { renameTask, replaceTask, addTask } = useTaskActions(); const notify = useToastNotification(); const spec = useSpec(); const task = useTask(entityId); @@ -95,20 +102,13 @@ export const TaskDetails = observer(function TaskDetails({ taskName={task.name} inputDiff={inputDiff} outputDiff={outputDiff} + allowPlace onChoose={onChoose} /> ); }; - const handleComponentSaved = ( - hydratedComponent: HydratedComponentReference, - action: SaveAction, - ) => { - if (action !== "update") { - // "place" arrives once placement ships; nothing else applies in place. - return; - } - + const updateInPlace = (hydratedComponent: HydratedComponentReference) => { const result = replaceTask(spec, task.$id, hydratedComponent); const lostInputs = result.inputDiff?.lostEntities ?? []; @@ -123,6 +123,54 @@ export const TaskDetails = observer(function TaskDetails({ } }; + const placeAsNewTask = (hydratedComponent: HydratedComponentReference) => { + // Positions live in task annotations; sizes aren't tracked there, so use + // the default node dimensions for overlap (the reveal animation handles + // any imprecision). + const ESTIMATED_NODE_HEIGHT = 120; + const toRect = (pos: { x: number; y: number }): Bounds => ({ + x: pos.x, + y: pos.y, + width: DEFAULT_NODE_DIMENSIONS.w, + height: ESTIMATED_NODE_HEIGHT, + }); + const positionOf = (taskId: string) => + [...spec.tasks] + .find((t) => t.$id === taskId) + ?.annotations.get(EDITOR_POSITION_ANNOTATION) as + | { x: number; y: number } + | undefined; + + const anchorRect = toRect(positionOf(task.$id) ?? { x: 0, y: 0 }); + const otherRects = [...spec.tasks] + .filter((t) => t.$id !== task.$id) + .map((t) => t.annotations.get(EDITOR_POSITION_ANNOTATION)) + .filter((pos): pos is { x: number; y: number } => pos != null) + .map(toRect); + + const position = computePlacementPosition(anchorRect, otherRects, { + prefer: "below", + }); + + const newTask = addTask(spec, hydratedComponent, position); + notify("Task added", "success"); + + // Reveal the new node: animate the viewport to it, then spotlight it. + editor.setPendingFocusNode(newTask.$id); + editor.setSpotlightNode(newTask.$id); + }; + + const handleComponentSaved = ( + hydratedComponent: HydratedComponentReference, + action: SaveAction, + ) => { + if (action === "update") { + updateInPlace(hydratedComponent); + } else if (action === "place") { + placeAsNewTask(hydratedComponent); + } + }; + const handleZIndexChange = (newZIndex: number) => { undo.withGroup("Update task z-index", () => { task.annotations.set(ZINDEX_ANNOTATION, newZIndex); diff --git a/src/routes/v2/shared/nodes/TaskNode/TaskNode.tsx b/src/routes/v2/shared/nodes/TaskNode/TaskNode.tsx index 7a27aa6c3..1613a1bf6 100644 --- a/src/routes/v2/shared/nodes/TaskNode/TaskNode.tsx +++ b/src/routes/v2/shared/nodes/TaskNode/TaskNode.tsx @@ -299,10 +299,20 @@ export const TaskNode = observer(function TaskNode({ onHandleClick: handleHandleClick, }; + // Briefly spotlight a node that was just placed on the canvas. Merges into + // any active overlay effect rather than replacing it. + const wrapperEffect: NodeOverlayEffect | undefined = + editor.spotlightNodeId === entityId + ? { + ...nodeEffect, + className: cn(nodeEffect?.className, "animate-spotlight rounded-2xl"), + } + : nodeEffect; + const OverrideComponent = nodeEffect?.componentOverride; if (OverrideComponent) { return ( - + ); @@ -310,14 +320,14 @@ export const TaskNode = observer(function TaskNode({ if (!showContent) { return ( - + ); } return ( - + ); diff --git a/src/routes/v2/shared/store/editorStore.test.ts b/src/routes/v2/shared/store/editorStore.test.ts new file mode 100644 index 000000000..9c2f6b4ed --- /dev/null +++ b/src/routes/v2/shared/store/editorStore.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { EditorStore } from "./editorStore"; + +describe("EditorStore.setSpotlightNode", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("sets the spotlight and auto-clears it after the reveal animation", () => { + const store = new EditorStore(); + + store.setSpotlightNode("task-1"); + expect(store.spotlightNodeId).toBe("task-1"); + + vi.advanceTimersByTime(1300); + expect(store.spotlightNodeId).toBeNull(); + }); + + it("resets the timer when the spotlight target changes", () => { + const store = new EditorStore(); + + store.setSpotlightNode("a"); + vi.advanceTimersByTime(1000); + store.setSpotlightNode("b"); + + // Only 1000ms since "b" was set — still spotlit. + vi.advanceTimersByTime(1000); + expect(store.spotlightNodeId).toBe("b"); + + vi.advanceTimersByTime(300); + expect(store.spotlightNodeId).toBeNull(); + }); + + it("clearing cancels the pending timer", () => { + const store = new EditorStore(); + + store.setSpotlightNode("a"); + store.setSpotlightNode(null); + expect(store.spotlightNodeId).toBeNull(); + + vi.advanceTimersByTime(1300); + expect(store.spotlightNodeId).toBeNull(); + }); +}); diff --git a/src/routes/v2/shared/store/editorStore.ts b/src/routes/v2/shared/store/editorStore.ts index e4dcd9b74..de7518dfc 100644 --- a/src/routes/v2/shared/store/editorStore.ts +++ b/src/routes/v2/shared/store/editorStore.ts @@ -19,9 +19,12 @@ export class EditorStore { @observable accessor focusedArgumentName: string | null = null; @observable accessor hoveredEntityId: string | null = null; @observable accessor pendingFocusNodeId: string | null = null; + @observable accessor spotlightNodeId: string | null = null; @observable.ref accessor selectedValidationIssue: ValidationIssue | null = null; + private spotlightTimer: ReturnType | null = null; + constructor() { makeObservable(this); } @@ -35,6 +38,11 @@ export class EditorStore { this.focusedArgumentName = null; this.hoveredEntityId = null; this.pendingFocusNodeId = null; + this.spotlightNodeId = null; + if (this.spotlightTimer !== null) { + clearTimeout(this.spotlightTimer); + this.spotlightTimer = null; + } this.selectedValidationIssue = null; } @@ -103,6 +111,27 @@ export class EditorStore { this.pendingFocusNodeId = nodeId; } + /** + * Briefly spotlight a node (e.g. one just placed on the canvas). Auto-clears + * after the reveal animation so the effect plays once. + */ + @action setSpotlightNode(nodeId: string | null) { + this.spotlightNodeId = nodeId; + if (this.spotlightTimer !== null) { + clearTimeout(this.spotlightTimer); + this.spotlightTimer = null; + } + if (nodeId !== null) { + this.spotlightTimer = setTimeout( + action(() => { + this.spotlightNodeId = null; + this.spotlightTimer = null; + }), + 1300, + ); + } + } + @action setSelectedValidationIssue(issue: ValidationIssue | null) { this.selectedValidationIssue = issue; }