Files
zui/frontend/src/components/graph/BaseNode.tsx
2026-03-12 15:31:21 +01:00

220 lines
6.7 KiB
TypeScript

import type { ComponentProps, ReactNode } from "react";
import { NodeResizer } from "@xyflow/react";
import { useContext } from "react";
import FlowContext from "@/lib/graph/flowContext";
import { useConnectionPathRole } from "@/lib/graph/flowContext";
import { cn } from "@/lib/utils";
/** 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 flowContext = useContext(FlowContext);
const isFullscreenInstance = Boolean(nodeId && flowContext?.fullscreenNodeId === nodeId);
const connectionPathRole = useConnectionPathRole(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 === "updating" && "connection-path-updating",
connectionPathRole === "on-path" && "connection-path-on-path",
className,
)}
data-selected={selected}
data-path-role={connectionPathRole ?? undefined}
style={appliedStyle}
tabIndex={0}
{...props}
>
<div
className="min-h-0 flex-1 flex flex-col overflow-hidden"
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 && 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,
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 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 border-border/70 pt-1 px-1 pb-1",
className,
)}
{...props}
/>
);
}