183 lines
4.8 KiB
TypeScript
183 lines
4.8 KiB
TypeScript
import type { ComponentProps, ReactNode } from "react";
|
|
import { NodeResizeControl } from "@xyflow/react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export type BaseNodeProps = ComponentProps<"div"> & {
|
|
/** When true, shows a bottom-right resize handle. 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 };
|
|
};
|
|
|
|
export function BaseNode({
|
|
className,
|
|
style,
|
|
dimensions,
|
|
resizable,
|
|
nodeId,
|
|
handles,
|
|
children,
|
|
...props
|
|
}: BaseNodeProps) {
|
|
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;
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"bg-card text-card-foreground relative rounded-md border",
|
|
"hover:ring-1",
|
|
// React Flow displays node elements inside of a `NodeWrapper` component,
|
|
// which compiles down to a div with the class `react-flow__node`.
|
|
// When a node is selected, the class `selected` is added to the
|
|
// `react-flow__node` element. This allows us to style the node when it
|
|
// is selected, using Tailwind's `&` selector.
|
|
"[.react-flow\\_\\_node.selected_&]:border-muted-foreground",
|
|
"[.react-flow\\_\\_node.selected_&]:shadow-lg",
|
|
className,
|
|
)}
|
|
style={appliedStyle}
|
|
tabIndex={0}
|
|
{...props}
|
|
>
|
|
<div
|
|
className="min-h-0 flex-1 flex flex-col overflow-hidden"
|
|
style={{ contain: "layout" }}
|
|
>
|
|
{children}
|
|
</div>
|
|
{resizable && nodeId && (
|
|
<NodeResizeControl
|
|
nodeId={nodeId}
|
|
position="bottom-right"
|
|
minWidth={120}
|
|
minHeight={80}
|
|
className={cn(
|
|
"!border-0 !bg-transparent !rounded-none !p-0",
|
|
"absolute bottom-0 right-0 w-8 h-8 cursor-se-resize select-none",
|
|
"nodrag nopan touch-none",
|
|
)}
|
|
style={{ margin: 0, touchAction: "none", userSelect: "none" }}
|
|
>
|
|
</NodeResizeControl>
|
|
)}
|
|
{handles}
|
|
</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,
|
|
className,
|
|
}: {
|
|
icon: ReactNode;
|
|
title: ReactNode;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<header
|
|
className={cn(
|
|
"shrink-0 mx-0 my-0 flex flex-row items-center justify-between gap-2 px-3 py-2",
|
|
className,
|
|
)}
|
|
>
|
|
{icon}
|
|
<h3
|
|
data-slot="base-node-title"
|
|
className="user-select-none flex-1 font-mono"
|
|
>
|
|
{title}
|
|
</h3>
|
|
</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 overflow-auto", 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 p-1",
|
|
className,
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|