Skip to content

Commit b144787

Browse files
committed
feat: "Place as a new task" with intelligent placement (v2 editor)
Brings the v2 editor to parity with the legacy "Place as a new task" action. On "place", instead of replacing the selected task, a NEW task is created from the edited definition, dropped above/below the selected task without overlapping, then the viewport animates to it and it spotlights briefly. - Reuses the shared `computePlacementPosition` geometry. v2 positions live in task annotations (sizes aren't tracked there), so overlap uses `DEFAULT_NODE_DIMENSIONS` + an estimated height; the reveal animation covers any imprecision. - `TaskDetails` enables `allowPlace` and, on "place", adds the task via the existing `useTaskActions().addTask` store action, then reveals it with `editor.setPendingFocusNode` (the existing `useFitViewOnFocus` animates the viewport) and `editor.setSpotlightNode`. - `EditorStore` gains a transient, self-clearing `spotlightNodeId` (`setSpotlightNode`, auto-clears after the ~1.2s reveal). `TaskNode` merges an `animate-spotlight` class into its node effect when it is the spotlighted node — no change to the canvas-overlay `isActive` semantics. Tests: `EditorStore.setSpotlightNode` (sets + auto-clears, timer reset on retarget, clear cancels the timer).
1 parent 9bba106 commit b144787

4 files changed

Lines changed: 145 additions & 15 deletions

File tree

src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/TaskDetails.tsx

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { observer } from "mobx-react-lite";
22
import { useEffect, useRef, useState } from "react";
33

44
import type { SaveAction } from "@/components/shared/ComponentEditor/saveAction";
5+
import { computePlacementPosition } from "@/components/shared/ReactFlow/FlowCanvas/utils/computePlacementPosition";
6+
import type { Bounds } from "@/components/shared/ReactFlow/FlowCanvas/utils/geometry";
57
import { StackingControls } from "@/components/shared/ReactFlow/FlowControls/StackingControls";
68
import { Button } from "@/components/ui/button";
79
import { Icon } from "@/components/ui/icon";
@@ -19,9 +21,14 @@ import { useTaskActions } from "@/routes/v2/pages/Editor/store/actions/useTaskAc
1921
import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext";
2022
import { useSpec } from "@/routes/v2/shared/providers/SpecContext";
2123
import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext";
22-
import { SYSTEM_ANNOTATIONS, ZINDEX_ANNOTATION } from "@/utils/annotations";
24+
import {
25+
EDITOR_POSITION_ANNOTATION,
26+
SYSTEM_ANNOTATIONS,
27+
ZINDEX_ANNOTATION,
28+
} from "@/utils/annotations";
2329
import type { HydratedComponentReference } from "@/utils/componentSpec";
2430
import { diffComponentIO } from "@/utils/componentSpecDiff";
31+
import { DEFAULT_NODE_DIMENSIONS } from "@/utils/constants";
2532
import { tracking } from "@/utils/tracking";
2633

2734
import { getTaskYamlText } from "./components/actions/getTaskYamlText";
@@ -44,7 +51,7 @@ export const TaskDetails = observer(function TaskDetails({
4451
const { track } = useAnalytics();
4552
const { editor } = useSharedStores();
4653
const { undo } = useEditorSession();
47-
const { renameTask, replaceTask } = useTaskActions();
54+
const { renameTask, replaceTask, addTask } = useTaskActions();
4855
const { open: openDialog } = useDialog();
4956
const notify = useToastNotification();
5057
const spec = useSpec();
@@ -91,20 +98,12 @@ export const TaskDetails = observer(function TaskDetails({
9198

9299
return openDialog({
93100
component: ChooseSaveActionDialog,
94-
props: { taskName: task.name, inputDiff, outputDiff },
101+
props: { taskName: task.name, inputDiff, outputDiff, allowPlace: true },
95102
size: "md",
96103
}).catch(convertCancelErrorTo<SaveAction>("cancel"));
97104
};
98105

99-
const handleComponentSaved = (
100-
hydratedComponent: HydratedComponentReference,
101-
action: SaveAction,
102-
) => {
103-
if (action !== "update") {
104-
// "place" arrives once placement ships; nothing else applies in place.
105-
return;
106-
}
107-
106+
const updateInPlace = (hydratedComponent: HydratedComponentReference) => {
108107
const result = replaceTask(spec, task.$id, hydratedComponent);
109108
const lostInputs = result.inputDiff?.lostEntities ?? [];
110109

@@ -119,6 +118,54 @@ export const TaskDetails = observer(function TaskDetails({
119118
}
120119
};
121120

121+
const placeAsNewTask = (hydratedComponent: HydratedComponentReference) => {
122+
// Positions live in task annotations; sizes aren't tracked there, so use
123+
// the default node dimensions for overlap (the reveal animation handles
124+
// any imprecision).
125+
const ESTIMATED_NODE_HEIGHT = 120;
126+
const toRect = (pos: { x: number; y: number }): Bounds => ({
127+
x: pos.x,
128+
y: pos.y,
129+
width: DEFAULT_NODE_DIMENSIONS.w,
130+
height: ESTIMATED_NODE_HEIGHT,
131+
});
132+
const positionOf = (taskId: string) =>
133+
[...spec.tasks]
134+
.find((t) => t.$id === taskId)
135+
?.annotations.get(EDITOR_POSITION_ANNOTATION) as
136+
| { x: number; y: number }
137+
| undefined;
138+
139+
const anchorRect = toRect(positionOf(task.$id) ?? { x: 0, y: 0 });
140+
const otherRects = [...spec.tasks]
141+
.filter((t) => t.$id !== task.$id)
142+
.map((t) => t.annotations.get(EDITOR_POSITION_ANNOTATION))
143+
.filter((pos): pos is { x: number; y: number } => pos != null)
144+
.map(toRect);
145+
146+
const position = computePlacementPosition(anchorRect, otherRects, {
147+
prefer: "below",
148+
});
149+
150+
const newTask = addTask(spec, hydratedComponent, position);
151+
notify("Task added", "success");
152+
153+
// Reveal the new node: animate the viewport to it, then spotlight it.
154+
editor.setPendingFocusNode(newTask.$id);
155+
editor.setSpotlightNode(newTask.$id);
156+
};
157+
158+
const handleComponentSaved = (
159+
hydratedComponent: HydratedComponentReference,
160+
action: SaveAction,
161+
) => {
162+
if (action === "update") {
163+
updateInPlace(hydratedComponent);
164+
} else if (action === "place") {
165+
placeAsNewTask(hydratedComponent);
166+
}
167+
};
168+
122169
const handleZIndexChange = (newZIndex: number) => {
123170
undo.withGroup("Update task z-index", () => {
124171
task.annotations.set(ZINDEX_ANNOTATION, newZIndex);

src/routes/v2/shared/nodes/TaskNode/TaskNode.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,25 +299,35 @@ export const TaskNode = observer(function TaskNode({
299299
onHandleClick: handleHandleClick,
300300
};
301301

302+
// Briefly spotlight a node that was just placed on the canvas. Merges into
303+
// any active overlay effect rather than replacing it.
304+
const wrapperEffect: NodeOverlayEffect | undefined =
305+
editor.spotlightNodeId === entityId
306+
? {
307+
...nodeEffect,
308+
className: cn(nodeEffect?.className, "animate-spotlight rounded-2xl"),
309+
}
310+
: nodeEffect;
311+
302312
const OverrideComponent = nodeEffect?.componentOverride;
303313
if (OverrideComponent) {
304314
return (
305-
<NodeEffectWrapper effect={nodeEffect}>
315+
<NodeEffectWrapper effect={wrapperEffect}>
306316
<OverrideComponent {...viewProps} />
307317
</NodeEffectWrapper>
308318
);
309319
}
310320

311321
if (!showContent) {
312322
return (
313-
<NodeEffectWrapper effect={nodeEffect}>
323+
<NodeEffectWrapper effect={wrapperEffect}>
314324
<TaskNodeSimplified {...viewProps} />
315325
</NodeEffectWrapper>
316326
);
317327
}
318328

319329
return (
320-
<NodeEffectWrapper effect={nodeEffect}>
330+
<NodeEffectWrapper effect={wrapperEffect}>
321331
<TaskNodeCard {...viewProps} />
322332
</NodeEffectWrapper>
323333
);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
import { EditorStore } from "./editorStore";
4+
5+
describe("EditorStore.setSpotlightNode", () => {
6+
beforeEach(() => vi.useFakeTimers());
7+
afterEach(() => vi.useRealTimers());
8+
9+
it("sets the spotlight and auto-clears it after the reveal animation", () => {
10+
const store = new EditorStore();
11+
12+
store.setSpotlightNode("task-1");
13+
expect(store.spotlightNodeId).toBe("task-1");
14+
15+
vi.advanceTimersByTime(1300);
16+
expect(store.spotlightNodeId).toBeNull();
17+
});
18+
19+
it("resets the timer when the spotlight target changes", () => {
20+
const store = new EditorStore();
21+
22+
store.setSpotlightNode("a");
23+
vi.advanceTimersByTime(1000);
24+
store.setSpotlightNode("b");
25+
26+
// Only 1000ms since "b" was set — still spotlit.
27+
vi.advanceTimersByTime(1000);
28+
expect(store.spotlightNodeId).toBe("b");
29+
30+
vi.advanceTimersByTime(300);
31+
expect(store.spotlightNodeId).toBeNull();
32+
});
33+
34+
it("clearing cancels the pending timer", () => {
35+
const store = new EditorStore();
36+
37+
store.setSpotlightNode("a");
38+
store.setSpotlightNode(null);
39+
expect(store.spotlightNodeId).toBeNull();
40+
41+
vi.advanceTimersByTime(1300);
42+
expect(store.spotlightNodeId).toBeNull();
43+
});
44+
});

src/routes/v2/shared/store/editorStore.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@ export class EditorStore {
1919
@observable accessor focusedArgumentName: string | null = null;
2020
@observable accessor hoveredEntityId: string | null = null;
2121
@observable accessor pendingFocusNodeId: string | null = null;
22+
@observable accessor spotlightNodeId: string | null = null;
2223
@observable.ref accessor selectedValidationIssue: ValidationIssue | null =
2324
null;
2425

26+
private spotlightTimer: ReturnType<typeof setTimeout> | null = null;
27+
2528
constructor() {
2629
makeObservable(this);
2730
}
@@ -35,6 +38,11 @@ export class EditorStore {
3538
this.focusedArgumentName = null;
3639
this.hoveredEntityId = null;
3740
this.pendingFocusNodeId = null;
41+
this.spotlightNodeId = null;
42+
if (this.spotlightTimer !== null) {
43+
clearTimeout(this.spotlightTimer);
44+
this.spotlightTimer = null;
45+
}
3846
this.selectedValidationIssue = null;
3947
}
4048

@@ -103,6 +111,27 @@ export class EditorStore {
103111
this.pendingFocusNodeId = nodeId;
104112
}
105113

114+
/**
115+
* Briefly spotlight a node (e.g. one just placed on the canvas). Auto-clears
116+
* after the reveal animation so the effect plays once.
117+
*/
118+
@action setSpotlightNode(nodeId: string | null) {
119+
this.spotlightNodeId = nodeId;
120+
if (this.spotlightTimer !== null) {
121+
clearTimeout(this.spotlightTimer);
122+
this.spotlightTimer = null;
123+
}
124+
if (nodeId !== null) {
125+
this.spotlightTimer = setTimeout(
126+
action(() => {
127+
this.spotlightNodeId = null;
128+
this.spotlightTimer = null;
129+
}),
130+
1300,
131+
);
132+
}
133+
}
134+
106135
@action setSelectedValidationIssue(issue: ValidationIssue | null) {
107136
this.selectedValidationIssue = issue;
108137
}

0 commit comments

Comments
 (0)