226 lines
6.9 KiB
TypeScript
226 lines
6.9 KiB
TypeScript
import type { ComponentProps, ReactNode } from "react";
|
|
import { NodeResizer } from "@xyflow/react";
|
|
import { useContext } from "react";
|
|
|
|
import { FlowUIContext } from "@/lib/graph/flowContext";
|
|
import { useConnectionPathRoleFromStore } from "@/app/canvas/useCanvasConnectionPathFromStore";
|
|
import { cn } from "@/lib/utils";
|
|
import { NodeRunStatusOverlay } from "./NodeRunStatusOverlay";
|
|
|
|
/** Default min size for resizable nodes (used by NodeResizer). */
|
|
export const RESIZE_MIN_WIDTH = 120;
|
|
export const RESIZE_MIN_HEIGHT = 80;
|
|
|
|
export type BaseNodeProps = ComponentProps<"div"> & {
|
|
/** When true, shows resize handles on corners/edges (via NodeResizer). Requires nodeId when used inside React Flow. */
|
|
resizable?: boolean;
|
|
/** Node id for the resize control (required when resizable is true). */
|
|
nodeId?: string;
|
|
/** Connection handles (InputHandle, OutputHandle). Rendered outside the overflow layer so they stay visible. */
|
|
handles?: ReactNode;
|
|
/** When provided, node uses this size (e.g. from React Flow width/height). Enables correct resize behavior. */
|
|
dimensions?: { width: number; height: number };
|
|
/** When true, node is selected (from React Flow NodeProps). Used for visible selected state. */
|
|
selected?: boolean;
|
|
/** Optional min/max for NodeResizer. Omit to use RESIZE_MIN_WIDTH / RESIZE_MIN_HEIGHT. */
|
|
resizeConstraints?: { minWidth?: number; minHeight?: number; maxWidth?: number; maxHeight?: number };
|
|
};
|
|
|
|
export function BaseNode({
|
|
className,
|
|
style,
|
|
dimensions,
|
|
resizable,
|
|
nodeId,
|
|
handles,
|
|
children,
|
|
selected,
|
|
resizeConstraints,
|
|
...props
|
|
}: BaseNodeProps) {
|
|
const flowUIContext = useContext(FlowUIContext);
|
|
const isFullscreenInstance = Boolean(nodeId && flowUIContext?.fullscreenNodeId === nodeId);
|
|
const connectionPathRole = useConnectionPathRoleFromStore(nodeId);
|
|
const hasSize =
|
|
dimensions &&
|
|
dimensions.width > 0 &&
|
|
dimensions.height > 0;
|
|
// Don't set overflow: hidden on the root — handles are positioned at left/right -10px
|
|
// and would be clipped. The inner content wrapper has overflow-hidden for scrolling.
|
|
const appliedStyle = hasSize
|
|
? {
|
|
...style,
|
|
width: dimensions.width,
|
|
height: dimensions.height,
|
|
display: "flex" as const,
|
|
flexDirection: "column" as const,
|
|
contain: "layout" as const,
|
|
}
|
|
: style
|
|
? { ...style, display: "flex", flexDirection: "column" as const }
|
|
: undefined;
|
|
|
|
const minW = resizeConstraints?.minWidth ?? RESIZE_MIN_WIDTH;
|
|
const minH = resizeConstraints?.minHeight ?? RESIZE_MIN_HEIGHT;
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"bg-card text-card-foreground relative rounded-md border transition-[border-color,box-shadow] duration-200",
|
|
"hover:ring-1",
|
|
selected && "border-primary/50 shadow-[0_0_0_2px_hsl(var(--primary)_/_0.15)] dark:border-primary/35 dark:shadow-[0_0_0_2px_hsl(var(--primary)_/_0.1)]",
|
|
connectionPathRole === "trigger" && "connection-path-trigger",
|
|
connectionPathRole === "on-path" && "connection-path-on-path",
|
|
className,
|
|
)}
|
|
data-selected={selected}
|
|
data-path-role={connectionPathRole ?? undefined}
|
|
style={appliedStyle}
|
|
tabIndex={selected ? 0 : -1}
|
|
{...props}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"min-h-0 flex-1 flex flex-col overflow-hidden",
|
|
!selected && "pointer-events-none",
|
|
)}
|
|
style={{ contain: "layout" }}
|
|
>
|
|
{children}
|
|
</div>
|
|
{resizable && nodeId && !isFullscreenInstance && (
|
|
<NodeResizer
|
|
nodeId={nodeId}
|
|
isVisible={selected}
|
|
minWidth={minW}
|
|
minHeight={minH}
|
|
maxWidth={resizeConstraints?.maxWidth}
|
|
maxHeight={resizeConstraints?.maxHeight}
|
|
color="transparent"
|
|
handleClassName="base-node-resize-handle nodrag nopan"
|
|
/>
|
|
)}
|
|
{!isFullscreenInstance && (
|
|
<div className={cn(!selected && "pointer-events-none")}>{handles}</div>
|
|
)}
|
|
{nodeId && <NodeRunStatusOverlay nodeId={nodeId} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A container for a consistent header layout intended to be used inside the
|
|
* `<BaseNode />` component.
|
|
*/
|
|
export function BaseNodeHeader({
|
|
className,
|
|
...props
|
|
}: ComponentProps<"header">) {
|
|
return (
|
|
<header
|
|
{...props}
|
|
className={cn(
|
|
"shrink-0 mx-0 my-0 -mb-1 flex flex-row items-center justify-between gap-2 px-3 py-2",
|
|
// Remove or modify these classes if you modify the padding in the
|
|
// `<BaseNode />` component.
|
|
className,
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Standard node header: icon on the left, title taking the rest. Use for all node types.
|
|
* Inlined so it does not depend on BaseNodeHeader/BaseNodeHeaderTitle (avoids reference errors with HMR).
|
|
*/
|
|
export function BaseNodeHeaderRow({
|
|
icon,
|
|
title,
|
|
right,
|
|
className,
|
|
onHeaderDoubleClick,
|
|
}: {
|
|
icon: ReactNode;
|
|
title: ReactNode;
|
|
/** Optional right-side content (e.g. type selector). */
|
|
right?: ReactNode;
|
|
className?: string;
|
|
/** When provided, double-click on the header triggers this (e.g. fullscreen). Header gets cursor-pointer and nodrag. */
|
|
onHeaderDoubleClick?: () => void;
|
|
}) {
|
|
return (
|
|
<header
|
|
role={onHeaderDoubleClick ? 'button' : undefined}
|
|
tabIndex={onHeaderDoubleClick ? 0 : undefined}
|
|
onDoubleClick={onHeaderDoubleClick}
|
|
onKeyDown={
|
|
onHeaderDoubleClick
|
|
? (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
onHeaderDoubleClick();
|
|
}
|
|
}
|
|
: undefined
|
|
}
|
|
className={cn(
|
|
"shrink-0 mx-0 my-0 flex flex-row items-center justify-between gap-2 px-3 py-2",
|
|
onHeaderDoubleClick && "cursor-pointer",
|
|
className,
|
|
)}
|
|
title={onHeaderDoubleClick ? 'Double-click for full screen' : undefined}
|
|
>
|
|
{icon}
|
|
<h3
|
|
data-slot="base-node-title"
|
|
className="user-select-none flex-1 font-mono min-w-0 truncate"
|
|
>
|
|
{title}
|
|
</h3>
|
|
{right != null ? <div className="shrink-0 nodrag nopan">{right}</div> : null}
|
|
</header>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Single-line muted text for the footer. Use for status or hints in all node types.
|
|
*/
|
|
export function BaseNodeFooterText({
|
|
className,
|
|
...props
|
|
}: ComponentProps<"div">) {
|
|
return (
|
|
<div
|
|
className={cn("w-full text-xs text-muted-foreground", className)}
|
|
data-slot="base-node-footer-text"
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function BaseNodeContent({
|
|
className,
|
|
...props
|
|
}: ComponentProps<"div">) {
|
|
return (
|
|
<div
|
|
data-slot="base-node-content"
|
|
className={cn("min-h-0 flex-1 flex flex-col", className)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function BaseNodeFooter({ className, ...props }: ComponentProps<"div">) {
|
|
return (
|
|
<div
|
|
data-slot="base-node-footer"
|
|
className={cn(
|
|
"shrink-0 flex flex-col items-center gap-y-2 border-t border-border/70 pt-1 px-1 pb-1",
|
|
className,
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|