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 @@ -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";
Expand All @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -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 ?? [];

Expand All @@ -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);
Expand Down
16 changes: 13 additions & 3 deletions src/routes/v2/shared/nodes/TaskNode/TaskNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -299,25 +299,35 @@ 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 (
<NodeEffectWrapper effect={nodeEffect}>
<NodeEffectWrapper effect={wrapperEffect}>
<OverrideComponent {...viewProps} />
</NodeEffectWrapper>
);
}

if (!showContent) {
return (
<NodeEffectWrapper effect={nodeEffect}>
<NodeEffectWrapper effect={wrapperEffect}>
<TaskNodeSimplified {...viewProps} />
</NodeEffectWrapper>
);
}

return (
<NodeEffectWrapper effect={nodeEffect}>
<NodeEffectWrapper effect={wrapperEffect}>
<TaskNodeCard {...viewProps} />
</NodeEffectWrapper>
);
Expand Down
44 changes: 44 additions & 0 deletions src/routes/v2/shared/store/editorStore.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
29 changes: 29 additions & 0 deletions src/routes/v2/shared/store/editorStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | null = null;

constructor() {
makeObservable(this);
}
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
}
Expand Down
Loading