feat(FN-3089): add draggable dependency graph nodes
Adds draggable interaction to dependency graph nodes (FN-3089), introducing a `useNodeDrag` hook to manage drag state and positioning, with corresponding tests and a new drag stylesheet; the `GraphTaskNode` and `DependencyGraph` components are updated to wire up the drag behavior. Fusion-Task-Id: FN-3089
This commit is contained in:
@@ -9,7 +9,7 @@ Plugin-provided top-level **Graph** dashboard view for Fusion.
|
||||
- **Orphan dependency handling**: if a visible task depends on an excluded/missing dependency (for example `done`/`archived` after filtering), the missing edge is silently dropped and graph rendering continues without broken connectors
|
||||
- **Auto-layout**: Sugiyama-style layered layout (`computeAutoLayout`) groups nodes by dependency depth and spaces layers consistently
|
||||
- **Edge drawing**: SVG bezier curves from source bottom-center to target top-center, with arrowheads showing dependent → dependency direction
|
||||
- **Interaction**: drag-to-pan canvas, cursor-centered wheel zoom, pinch-to-zoom with stationary midpoint, keyboard shortcuts, zoom toolbar, reset, and fit-to-graph
|
||||
- **Interaction**: drag-to-pan canvas background, drag-to-reposition nodes, cursor-centered wheel zoom, pinch-to-zoom with stationary midpoint, keyboard shortcuts, zoom toolbar, reset, and fit-to-graph
|
||||
- **Fit-to-graph**: computes node bounding box with layout node dimensions and applies zoom/pan so the graph fits in viewport with padding
|
||||
- **Initial auto-fit**: when no saved scoped positions exist, the first non-empty render auto-fits once; subsequent updates preserve user navigation state
|
||||
- **Animated transitions**: fit/reset operations animate `transform` (`var(--transition-normal)`), while continuous drag/wheel/pinch stays transition-free for responsiveness
|
||||
|
||||
@@ -74,10 +74,22 @@ export function DependencyGraph({
|
||||
const activeTaskId = hoveredTaskId ?? selectedTaskId;
|
||||
const highlightedTaskIds = useMemo(() => (activeTaskId ? getChain(activeTaskId) : new Set<string>()), [activeTaskId, getChain]);
|
||||
|
||||
const positions = useMemo(
|
||||
const autoLayoutPositions = useMemo(
|
||||
() => computeAutoLayout(graphData, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT, horizontalGap: 40, verticalGap: 80 }),
|
||||
[graphData],
|
||||
);
|
||||
const [positions, setPositions] = useState<Map<string, { x: number; y: number }>>(autoLayoutPositions);
|
||||
const [isNodeDragging, setIsNodeDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPositions((current) => {
|
||||
const next = new Map<string, { x: number; y: number }>();
|
||||
for (const [taskId, layoutPosition] of autoLayoutPositions.entries()) {
|
||||
next.set(taskId, current.get(taskId) ?? layoutPosition);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [autoLayoutPositions]);
|
||||
|
||||
const {
|
||||
transform,
|
||||
@@ -125,11 +137,13 @@ export function DependencyGraph({
|
||||
ref={viewportRef}
|
||||
className="dependency-graph__viewport"
|
||||
onPointerDown={(event) => {
|
||||
if (isNodeDragging) return;
|
||||
pointerDownRef.current = { x: event.clientX, y: event.clientY };
|
||||
pointerDraggedRef.current = false;
|
||||
onPointerDown(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (isNodeDragging) return;
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const pointerDown = pointerDownRef.current;
|
||||
@@ -143,11 +157,15 @@ export function DependencyGraph({
|
||||
onPointerMove(event.pointerId, { x: event.clientX, y: event.clientY }, viewport.clientWidth, viewport.clientHeight);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
onPointerUp(event.pointerId);
|
||||
if (!isNodeDragging) {
|
||||
onPointerUp(event.pointerId);
|
||||
}
|
||||
pointerDownRef.current = null;
|
||||
}}
|
||||
onPointerCancel={(event) => {
|
||||
onPointerUp(event.pointerId);
|
||||
if (!isNodeDragging) {
|
||||
onPointerUp(event.pointerId);
|
||||
}
|
||||
pointerDownRef.current = null;
|
||||
pointerDraggedRef.current = false;
|
||||
}}
|
||||
@@ -166,7 +184,7 @@ export function DependencyGraph({
|
||||
tabIndex={0}
|
||||
style={{ outline: "none" }}
|
||||
onClick={() => {
|
||||
if (pointerDraggedRef.current) return;
|
||||
if (pointerDraggedRef.current || isNodeDragging) return;
|
||||
setSelectedTaskId(null);
|
||||
}}
|
||||
>
|
||||
@@ -200,6 +218,18 @@ export function DependencyGraph({
|
||||
task={node.task}
|
||||
projectId={projectId}
|
||||
style={{ minHeight: `${NODE_HEIGHT}px`, left: `${position.x}px`, top: `${position.y}px` }}
|
||||
position={position}
|
||||
scale={zoom}
|
||||
onNodePositionChange={(taskId, nextPosition) => {
|
||||
setPositions((current) => {
|
||||
const existing = current.get(taskId);
|
||||
if (existing && existing.x === nextPosition.x && existing.y === nextPosition.y) return current;
|
||||
const next = new Map(current);
|
||||
next.set(taskId, nextPosition);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onNodeDragStateChange={setIsNodeDragging}
|
||||
isHighlighted={highlightedTaskIds.size > 0 && highlightedTaskIds.has(node.task.id)}
|
||||
isDimmed={highlightedTaskIds.size > 0 && !highlightedTaskIds.has(node.task.id)}
|
||||
onOpenDetail={onOpenDetail ?? ((task) => onOpenTaskDetail?.(task.id))}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { CSSProperties, ComponentProps, HTMLAttributes } from "react";
|
||||
import type { GraphPosition } from "./types";
|
||||
import { useNodeDrag } from "./hooks/useNodeDrag";
|
||||
import { TaskCard } from "@fusion/dashboard/app/components/TaskCard";
|
||||
import { isTaskStuck } from "@fusion/dashboard/app/utils/taskStuck";
|
||||
import "./GraphTaskNode.css";
|
||||
import "./GraphHighlight.css";
|
||||
import "./styles/drag.css";
|
||||
|
||||
type TaskCardComponentProps = ComponentProps<typeof TaskCard>;
|
||||
|
||||
@@ -28,8 +31,12 @@ type TaskCardBridgeProps = Pick<
|
||||
|
||||
export interface GraphTaskNodeProps extends TaskCardBridgeProps, Pick<HTMLAttributes<HTMLDivElement>, "onMouseEnter" | "onMouseLeave" | "onClick"> {
|
||||
style?: CSSProperties;
|
||||
position: GraphPosition;
|
||||
scale: number;
|
||||
isHighlighted?: boolean;
|
||||
isDimmed?: boolean;
|
||||
onNodePositionChange: (taskId: string, position: GraphPosition) => void;
|
||||
onNodeDragStateChange?: (isDragging: boolean) => void;
|
||||
}
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
|
||||
@@ -44,11 +51,15 @@ function getStatusLabel(status?: string): string {
|
||||
|
||||
export function GraphTaskNode({
|
||||
style,
|
||||
position,
|
||||
scale,
|
||||
isHighlighted = false,
|
||||
isDimmed = false,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onClick,
|
||||
onNodePositionChange,
|
||||
onNodeDragStateChange,
|
||||
...taskCardProps
|
||||
}: GraphTaskNodeProps) {
|
||||
const { task, globalPaused, taskStuckTimeoutMs, lastFetchTimeMs } = taskCardProps;
|
||||
@@ -71,9 +82,17 @@ export function GraphTaskNode({
|
||||
task.currentStep < task.steps.length;
|
||||
const isInReview = task.column === "in-review";
|
||||
|
||||
const drag = useNodeDrag({
|
||||
taskId: task.id,
|
||||
position,
|
||||
scale,
|
||||
onPositionChange: onNodePositionChange,
|
||||
onDragStateChange: onNodeDragStateChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`graph-task-node${isHighlighted ? " graph-task-node--highlighted graph-node--highlighted" : ""}${isDimmed ? " graph-task-node--dimmed graph-node--dimmed" : ""}${isActive ? " graph-task-node--active" : ""}${isInReview ? " graph-task-node--in-review" : ""}`}
|
||||
className={`graph-task-node graph-node--draggable${drag.isDragging ? " graph-node--dragging" : ""}${isHighlighted ? " graph-task-node--highlighted graph-node--highlighted" : ""}${isDimmed ? " graph-task-node--dimmed graph-node--dimmed" : ""}${isActive ? " graph-task-node--active" : ""}${isInReview ? " graph-task-node--in-review" : ""}`}
|
||||
style={style}
|
||||
draggable={false}
|
||||
data-testid={`graph-task-node-${task.id}`}
|
||||
@@ -81,6 +100,11 @@ export function GraphTaskNode({
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={onClick}
|
||||
onClickCapture={drag.onClickCapture}
|
||||
onPointerDown={drag.onPointerDown}
|
||||
onPointerMove={drag.onPointerMove}
|
||||
onPointerUp={drag.onPointerUp}
|
||||
onPointerCancel={drag.onPointerCancel}
|
||||
>
|
||||
{isActive ? (
|
||||
<div className="graph-task-active-indicator">
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { GraphTaskNode } from "../GraphTaskNode";
|
||||
|
||||
function task(id = "FN-1"): Task {
|
||||
return { id, description: id, column: "todo", dependencies: [], steps: [], currentStep: 0, log: [] } as Task;
|
||||
}
|
||||
|
||||
function props(overrides: Partial<React.ComponentProps<typeof GraphTaskNode>> = {}): React.ComponentProps<typeof GraphTaskNode> {
|
||||
return {
|
||||
task: task(),
|
||||
projectId: "p1",
|
||||
position: { x: 0, y: 0 },
|
||||
scale: 1,
|
||||
isHighlighted: false,
|
||||
isDimmed: false,
|
||||
onNodePositionChange: vi.fn(),
|
||||
onNodeDragStateChange: vi.fn(),
|
||||
onOpenDetail: vi.fn(),
|
||||
addToast: vi.fn(),
|
||||
onUpdateTask: vi.fn(),
|
||||
onArchiveTask: vi.fn(),
|
||||
onUnarchiveTask: vi.fn(),
|
||||
onDeleteTask: vi.fn(),
|
||||
onRetryTask: vi.fn(),
|
||||
onOpenDetailWithTab: vi.fn(),
|
||||
onMoveTask: vi.fn(),
|
||||
onOpenMission: vi.fn(),
|
||||
taskStuckTimeoutMs: 1000,
|
||||
lastFetchTimeMs: Date.now(),
|
||||
workflowStepNameLookup: new Map<string, string>(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("GraphTaskNode drag", () => {
|
||||
it("applies dragging class only after threshold move", () => {
|
||||
const onNodePositionChange = vi.fn();
|
||||
render(<GraphTaskNode {...props({ onNodePositionChange })} />);
|
||||
const node = screen.getByTestId("graph-task-node-FN-1");
|
||||
|
||||
fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
|
||||
fireEvent.pointerMove(node, { pointerId: 1, clientX: 12, clientY: 12, isPrimary: true });
|
||||
expect(node.className).not.toContain("graph-node--dragging");
|
||||
|
||||
fireEvent.pointerMove(node, { pointerId: 1, clientX: 20, clientY: 20, isPrimary: true });
|
||||
expect(node.className).toContain("graph-node--dragging");
|
||||
expect(onNodePositionChange).toHaveBeenCalled();
|
||||
|
||||
fireEvent.pointerUp(node, { pointerId: 1, clientX: 20, clientY: 20, isPrimary: true });
|
||||
expect(node.className).not.toContain("graph-node--dragging");
|
||||
});
|
||||
|
||||
it("composes highlight and dragging classes", () => {
|
||||
render(<GraphTaskNode {...props({ isHighlighted: true })} />);
|
||||
const node = screen.getByTestId("graph-task-node-FN-1");
|
||||
fireEvent.pointerDown(node, { pointerId: 1, clientX: 0, clientY: 0, isPrimary: true });
|
||||
fireEvent.pointerMove(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
|
||||
|
||||
expect(node.className).toContain("graph-task-node--highlighted");
|
||||
expect(node.className).toContain("graph-node--dragging");
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,10 @@ function createTask(overrides: Partial<Task> = {}): Task {
|
||||
function createProps(task: Task) {
|
||||
return {
|
||||
task,
|
||||
position: { x: 0, y: 0 },
|
||||
scale: 1,
|
||||
onNodePositionChange: vi.fn(),
|
||||
onNodeDragStateChange: vi.fn(),
|
||||
projectId: "proj-1",
|
||||
onOpenDetail: vi.fn(),
|
||||
addToast: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { __internal, useNodeDrag } from "../hooks/useNodeDrag";
|
||||
|
||||
function pointerEvent(overrides: Partial<PointerEvent> = {}) {
|
||||
const target = {
|
||||
setPointerCapture: vi.fn(),
|
||||
releasePointerCapture: vi.fn(),
|
||||
hasPointerCapture: vi.fn(() => true),
|
||||
};
|
||||
return {
|
||||
isPrimary: true,
|
||||
pointerId: 1,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
stopPropagation: vi.fn(),
|
||||
currentTarget: target,
|
||||
...overrides,
|
||||
} as unknown as React.PointerEvent<HTMLElement>;
|
||||
}
|
||||
|
||||
describe("useNodeDrag", () => {
|
||||
it("transitions pending to dragging and back on pointer up", () => {
|
||||
const onPositionChange = vi.fn();
|
||||
const onDragStateChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useNodeDrag({ taskId: "A", position: { x: 10, y: 10 }, scale: 1, onPositionChange, onDragStateChange }),
|
||||
);
|
||||
|
||||
act(() => result.current.onPointerDown(pointerEvent({ clientX: 10, clientY: 20 })));
|
||||
act(() => result.current.onPointerMove(pointerEvent({ clientX: 16, clientY: 26 })));
|
||||
expect(result.current.isDragging).toBe(true);
|
||||
expect(onPositionChange).toHaveBeenCalledWith("A", { x: 16, y: 16 });
|
||||
|
||||
act(() => result.current.onPointerUp(pointerEvent({ clientX: 16, clientY: 26 })));
|
||||
expect(result.current.isDragging).toBe(false);
|
||||
expect(onDragStateChange).toHaveBeenCalledWith(true);
|
||||
expect(onDragStateChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("stays click-only below threshold", () => {
|
||||
const onPositionChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, onPositionChange }),
|
||||
);
|
||||
|
||||
act(() => result.current.onPointerDown(pointerEvent()));
|
||||
act(() => result.current.onPointerMove(pointerEvent({ clientX: __internal.DRAG_THRESHOLD_PX - 1, clientY: 0 })));
|
||||
act(() => result.current.onPointerUp(pointerEvent({ clientX: __internal.DRAG_THRESHOLD_PX - 1, clientY: 0 })));
|
||||
|
||||
expect(result.current.isDragging).toBe(false);
|
||||
expect(onPositionChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("divides pointer delta by zoom scale", () => {
|
||||
const onPositionChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 2, onPositionChange }),
|
||||
);
|
||||
|
||||
act(() => result.current.onPointerDown(pointerEvent()));
|
||||
act(() => result.current.onPointerMove(pointerEvent({ clientX: 10, clientY: 6 })));
|
||||
|
||||
expect(onPositionChange).toHaveBeenCalledWith("A", { x: 5, y: 3 });
|
||||
});
|
||||
|
||||
it("cancels drag cleanly on pointer cancel", () => {
|
||||
const onPositionChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, onPositionChange }),
|
||||
);
|
||||
|
||||
act(() => result.current.onPointerDown(pointerEvent()));
|
||||
act(() => result.current.onPointerMove(pointerEvent({ clientX: 8, clientY: 0 })));
|
||||
expect(result.current.isDragging).toBe(true);
|
||||
|
||||
act(() => result.current.onPointerCancel(pointerEvent({ clientX: 8, clientY: 0 })));
|
||||
expect(result.current.isDragging).toBe(false);
|
||||
});
|
||||
});
|
||||
112
plugins/fusion-plugin-dependency-graph/src/hooks/useNodeDrag.ts
Normal file
112
plugins/fusion-plugin-dependency-graph/src/hooks/useNodeDrag.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
import type { GraphPosition } from "../types";
|
||||
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
interface UseNodeDragOptions {
|
||||
taskId: string;
|
||||
position: GraphPosition;
|
||||
scale: number;
|
||||
onPositionChange: (taskId: string, position: GraphPosition) => void;
|
||||
onDragStateChange?: (isDragging: boolean) => void;
|
||||
}
|
||||
|
||||
interface PendingState {
|
||||
pointerId: number;
|
||||
startPointer: { x: number; y: number };
|
||||
startPosition: GraphPosition;
|
||||
}
|
||||
|
||||
export function useNodeDrag({ taskId, position, scale, onPositionChange, onDragStateChange }: UseNodeDragOptions) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const pendingRef = useRef<PendingState | null>(null);
|
||||
const positionRef = useRef(position);
|
||||
const suppressClickRef = useRef(false);
|
||||
|
||||
positionRef.current = position;
|
||||
|
||||
const endDrag = useCallback((dragging: boolean) => {
|
||||
pendingRef.current = null;
|
||||
setIsDragging(false);
|
||||
if (dragging) {
|
||||
onDragStateChange?.(false);
|
||||
suppressClickRef.current = true;
|
||||
}
|
||||
}, [onDragStateChange]);
|
||||
|
||||
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (!event.isPrimary) return;
|
||||
event.stopPropagation();
|
||||
const currentTarget = event.currentTarget;
|
||||
if (typeof currentTarget.setPointerCapture === "function") {
|
||||
currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
pendingRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startPointer: { x: event.clientX, y: event.clientY },
|
||||
startPosition: positionRef.current,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onPointerMove = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||
const pending = pendingRef.current;
|
||||
if (!pending || pending.pointerId !== event.pointerId) return;
|
||||
event.stopPropagation();
|
||||
|
||||
const deltaX = event.clientX - pending.startPointer.x;
|
||||
const deltaY = event.clientY - pending.startPointer.y;
|
||||
const distance = Math.hypot(deltaX, deltaY);
|
||||
|
||||
if (!isDragging && distance >= DRAG_THRESHOLD_PX) {
|
||||
setIsDragging(true);
|
||||
onDragStateChange?.(true);
|
||||
}
|
||||
|
||||
if (distance < DRAG_THRESHOLD_PX) return;
|
||||
|
||||
const safeScale = scale > 0 ? scale : 1;
|
||||
onPositionChange(taskId, {
|
||||
x: pending.startPosition.x + deltaX / safeScale,
|
||||
y: pending.startPosition.y + deltaY / safeScale,
|
||||
});
|
||||
}, [isDragging, onDragStateChange, onPositionChange, scale, taskId]);
|
||||
|
||||
const onPointerUp = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||
const pending = pendingRef.current;
|
||||
if (!pending || pending.pointerId !== event.pointerId) return;
|
||||
event.stopPropagation();
|
||||
if (typeof event.currentTarget.hasPointerCapture === "function" && event.currentTarget.hasPointerCapture(event.pointerId) && typeof event.currentTarget.releasePointerCapture === "function") {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
endDrag(isDragging);
|
||||
}, [endDrag, isDragging]);
|
||||
|
||||
const onPointerCancel = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||
const pending = pendingRef.current;
|
||||
if (!pending || pending.pointerId !== event.pointerId) return;
|
||||
event.stopPropagation();
|
||||
if (typeof event.currentTarget.hasPointerCapture === "function" && event.currentTarget.hasPointerCapture(event.pointerId) && typeof event.currentTarget.releasePointerCapture === "function") {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
endDrag(isDragging);
|
||||
}, [endDrag, isDragging]);
|
||||
|
||||
const onClickCapture = useCallback((event: ReactMouseEvent<HTMLElement>) => {
|
||||
if (!suppressClickRef.current) return;
|
||||
suppressClickRef.current = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return useMemo(() => ({
|
||||
isDragging,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel,
|
||||
onClickCapture,
|
||||
}), [isDragging, onClickCapture, onPointerCancel, onPointerDown, onPointerMove, onPointerUp]);
|
||||
}
|
||||
|
||||
export const __internal = { DRAG_THRESHOLD_PX };
|
||||
15
plugins/fusion-plugin-dependency-graph/src/styles/drag.css
Normal file
15
plugins/fusion-plugin-dependency-graph/src/styles/drag.css
Normal file
@@ -0,0 +1,15 @@
|
||||
.graph-node--draggable {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.graph-node--dragging {
|
||||
z-index: 3;
|
||||
cursor: grabbing;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: scale(1.02);
|
||||
transition:
|
||||
transform var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
z-index var(--transition-fast);
|
||||
}
|
||||
Reference in New Issue
Block a user