improve drag

This commit is contained in:
2026-03-07 11:04:07 +01:00
parent 59224880d2
commit 6572052dba
6 changed files with 145 additions and 61 deletions

View File

@@ -1,6 +1,5 @@
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
import { NodeResizeControl } from "@xyflow/react"; import { NodeResizeControl } from "@xyflow/react";
import { Expand } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -11,20 +10,38 @@ export type BaseNodeProps = ComponentProps<"div"> & {
nodeId?: string; nodeId?: string;
/** Connection handles (InputHandle, OutputHandle). Rendered outside the overflow layer so they stay visible. */ /** Connection handles (InputHandle, OutputHandle). Rendered outside the overflow layer so they stay visible. */
handles?: ReactNode; 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({ export function BaseNode({
className, className,
style, style,
dimensions,
resizable, resizable,
nodeId, nodeId,
handles, handles,
children, children,
...props ...props
}: BaseNodeProps) { }: BaseNodeProps) {
const appliedStyle = style const hasSize =
? { ...style, display: "flex", flexDirection: "column" as const } dimensions &&
: undefined; 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 ( return (
<div <div
@@ -44,28 +61,31 @@ export function BaseNode({
tabIndex={0} tabIndex={0}
{...props} {...props}
> >
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">{children}</div> <div
className="min-h-0 flex-1 flex flex-col overflow-hidden"
style={{ contain: "layout" }}
>
{children}
</div>
{resizable && nodeId && ( {resizable && nodeId && (
<div <NodeResizeControl
className="flex shrink-0 items-center justify-end border-t border-border bg-muted/30 px-2 py-1.5" nodeId={nodeId}
data-slot="base-node-resize-footer" 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 <span
nodeId={nodeId} className="absolute bottom-0 right-0 w-8 h-8 rounded-br border-l border-t border-border/30 hover:border-border/50"
position="bottom-right" title="Drag corner to resize"
minWidth={120} aria-label="Resize node"
minHeight={80} />
className="!border-0 !bg-transparent !rounded-none" </NodeResizeControl>
style={{ padding: 0 }}
>
<span
className="flex size-6 items-center justify-center rounded border border-border bg-muted/80 text-muted-foreground shadow-sm nodrag nopan cursor-se-resize hover:bg-muted"
title="Drag to resize"
>
<Expand className="size-3.5" />
</span>
</NodeResizeControl>
</div>
)} )}
{handles} {handles}
</div> </div>
@@ -110,6 +130,53 @@ export function BaseNodeHeaderTitle({
); );
} }
/**
* 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 -mb-1 flex flex-row items-center justify-between gap-2 border-b px-3 py-2",
className,
)}
>
{icon}
<h3
data-slot="base-node-title"
className="user-select-none flex-1 font-semibold"
>
{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({ export function BaseNodeContent({
className, className,
...props ...props

View File

@@ -8,8 +8,8 @@ import {
BaseNode, BaseNode,
BaseNodeContent, BaseNodeContent,
BaseNodeFooter, BaseNodeFooter,
BaseNodeHeader, BaseNodeFooterText,
BaseNodeHeaderTitle, BaseNodeHeaderRow,
} from './BaseNode' } from './BaseNode'
import { GitBranchPlus, ScrollText } from 'lucide-react' import { GitBranchPlus, ScrollText } from 'lucide-react'
import { import {
@@ -119,12 +119,14 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
const extensions = useMemo(() => [plantumlLanguage.extension], []) const extensions = useMemo(() => [plantumlLanguage.extension], [])
const [editorHeight, editorContainerRef] = useResizeHeight(180) const [editorHeight, editorContainerRef] = useResizeHeight(180)
const dimensions =
width != null && height != null && width > 0 && height > 0
? { width, height }
: undefined
return ( return (
<BaseNode className="min-w-80 min-h-[280px]" style={width != null && height != null && width > 0 && height > 0 ? { width, height } : undefined} resizable nodeId={id} handles={<><InputHandle id="ain" /><OutputHandle id="out" /></>}> <BaseNode className="min-w-80 min-h-[280px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="ain" /><OutputHandle id="out" /></>}>
<BaseNodeHeader className="border-b"> <BaseNodeHeaderRow icon={<ScrollText className="size-4" />} title={`${id}.puml`} />
<ScrollText className="size-4" />
<BaseNodeHeaderTitle>{id}.puml</BaseNodeHeaderTitle>
</BaseNodeHeader>
<BaseNodeContent> <BaseNodeContent>
{hasDependencies && ( {hasDependencies && (
@@ -214,7 +216,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
</BaseNodeContent> </BaseNodeContent>
<BaseNodeFooter> <BaseNodeFooter>
<div className="w-full text-xs text-muted-foreground">{storedPlantuml ? `${storedPlantuml.length} chars` : 'none'}</div> <BaseNodeFooterText>{storedPlantuml ? `${storedPlantuml.length} chars` : 'none'}</BaseNodeFooterText>
</BaseNodeFooter> </BaseNodeFooter>
</BaseNode> </BaseNode>
) )

View File

@@ -8,8 +8,8 @@ import {
BaseNode, BaseNode,
BaseNodeContent, BaseNodeContent,
BaseNodeFooter, BaseNodeFooter,
BaseNodeHeader, BaseNodeFooterText,
BaseNodeHeaderTitle, BaseNodeHeaderRow,
} from './BaseNode' } from './BaseNode'
import { InputHandle, OutputHandle } from './NodeHandles' import { InputHandle, OutputHandle } from './NodeHandles'
import { Code2 } from 'lucide-react' import { Code2 } from 'lucide-react'
@@ -17,7 +17,8 @@ import { Code2 } from 'lucide-react'
type Props = { type Props = {
id: string id: string
data: { body?: string } data: { body?: string }
style?: React.CSSProperties width?: number
height?: number
} }
const DEFAULT_BODY = `// args[0], args[1], ... are the variable values (in order) const DEFAULT_BODY = `// args[0], args[1], ... are the variable values (in order)
@@ -55,13 +56,14 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
const extensions = useMemo(() => [javascript()], []) const extensions = useMemo(() => [javascript()], [])
const [editorHeight, editorContainerRef] = useResizeHeight(120) const [editorHeight, editorContainerRef] = useResizeHeight(120)
const dimensions =
width != null && height != null && width > 0 && height > 0
? { width, height }
: undefined
return ( return (
<BaseNode className="min-w-72 min-h-[260px]" style={width != null && height != null && width > 0 && height > 0 ? { width, height } : undefined} resizable nodeId={id} handles={<><InputHandle id="in" /><OutputHandle id="out" /></>}> <BaseNode className="min-w-72 min-h-[260px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="in" /><OutputHandle id="out" /></>}>
<BaseNodeHeader className="border-b"> <BaseNodeHeaderRow icon={<Code2 className="size-4" />} title={id} />
<Code2 className="size-4" />
<BaseNodeHeaderTitle>{id}</BaseNodeHeaderTitle>
</BaseNodeHeader>
<BaseNodeContent> <BaseNodeContent>
<p className="shrink-0 text-[10px] text-muted-foreground mb-1"> <p className="shrink-0 text-[10px] text-muted-foreground mb-1">
@@ -81,9 +83,7 @@ export const FunctionNode = memo(function FunctionNode({ id, data, width, height
</BaseNodeContent> </BaseNodeContent>
<BaseNodeFooter> <BaseNodeFooter>
<div className="w-full text-xs text-muted-foreground"> <BaseNodeFooterText>Body: {storedBody ? `${storedBody.length} chars` : 'none'}</BaseNodeFooterText>
Body: {storedBody ? `${storedBody.length} chars` : 'none'}
</div>
</BaseNodeFooter> </BaseNodeFooter>
</BaseNode> </BaseNode>
) )

View File

@@ -6,8 +6,8 @@ import {
BaseNode, BaseNode,
BaseNodeContent, BaseNodeContent,
BaseNodeFooter, BaseNodeFooter,
BaseNodeHeader, BaseNodeFooterText,
BaseNodeHeaderTitle, BaseNodeHeaderRow,
} from './BaseNode' } from './BaseNode'
import { Sparkles } from 'lucide-react' import { Sparkles } from 'lucide-react'
import { InputHandle, OutputHandle } from './NodeHandles' import { InputHandle, OutputHandle } from './NodeHandles'
@@ -266,12 +266,14 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
// Do not depend on nodes/edges refs to avoid flicker from unnecessary re-renders. // Do not depend on nodes/edges refs to avoid flicker from unnecessary re-renders.
}, [id, theme, plantumlText, configSignature, edgesSignature, variablesSignature, functionsSignature]) }, [id, theme, plantumlText, configSignature, edgesSignature, variablesSignature, functionsSignature])
const dimensions =
width != null && height != null && width > 0 && height > 0
? { width, height }
: undefined
return ( return (
<BaseNode className="min-w-96 min-h-[320px]" style={width != null && height != null && width > 0 && height > 0 ? { width, height } : undefined} resizable nodeId={id} handles={<><InputHandle id="ain" /><OutputHandle id="out" /></>}> <BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="ain" /><OutputHandle id="out" /></>}>
<BaseNodeHeader className="border-b"> <BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={id} />
<Sparkles className="size-4" />
<BaseNodeHeaderTitle>{id}</BaseNodeHeaderTitle>
</BaseNodeHeader>
<BaseNodeContent> <BaseNodeContent>
<div className="min-h-0 flex-1 flex flex-col"> <div className="min-h-0 flex-1 flex flex-col">
@@ -324,9 +326,9 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }:
</BaseNodeContent> </BaseNodeContent>
<BaseNodeFooter> <BaseNodeFooter>
<div className="w-full text-xs text-muted-foreground"> <BaseNodeFooterText>
{svgContent ? 'PlantUML diagram' : error ? 'Error' : '—'} {svgContent ? 'PlantUML diagram' : error ? 'Error' : '—'}
</div> </BaseNodeFooterText>
</BaseNodeFooter> </BaseNodeFooter>
</BaseNode> </BaseNode>
) )

View File

@@ -3,8 +3,9 @@ import FlowContext from '../../lib/flowContext'
import { import {
BaseNode, BaseNode,
BaseNodeContent, BaseNodeContent,
BaseNodeHeader, BaseNodeFooter,
BaseNodeHeaderTitle, BaseNodeFooterText,
BaseNodeHeaderRow,
} from './BaseNode' } from './BaseNode'
import { Input } from '../ui/input' import { Input } from '../ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
@@ -89,10 +90,7 @@ export const VariableNode = memo(function VariableNode({ id, data }: Props) {
return ( return (
<BaseNode className="min-w-56 min-h-[180px]" handles={<OutputHandle id="out" />}> <BaseNode className="min-w-56 min-h-[180px]" handles={<OutputHandle id="out" />}>
<BaseNodeHeader className="border-b"> <BaseNodeHeaderRow icon={<Variable className="size-4" />} title={id} />
<Variable className="size-4" />
<BaseNodeHeaderTitle>{id}</BaseNodeHeaderTitle>
</BaseNodeHeader>
<BaseNodeContent className="gap-2 p-3"> <BaseNodeContent className="gap-2 p-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@@ -126,10 +124,13 @@ export const VariableNode = memo(function VariableNode({ id, data }: Props) {
/> />
)} )}
</div> </div>
<p className="text-[10px] text-muted-foreground">
Use in config: <code className="rounded bg-muted px-0.5">prop: $&#123;{id}&#125;</code>
</p>
</BaseNodeContent> </BaseNodeContent>
<BaseNodeFooter>
<BaseNodeFooterText>
Use in config: <code className="rounded bg-muted px-0.5">prop: {'${'}{id}{'}'}</code>
</BaseNodeFooterText>
</BaseNodeFooter>
</BaseNode> </BaseNode>
) )
}) })

View File

@@ -51,6 +51,18 @@ body {
} }
} }
/* Resize control: snappy drag, no transition or selection delay */
.react-flow__resize-control.bottom.right {
cursor: se-resize;
touch-action: none;
user-select: none;
}
.react-flow__resize-control.bottom.right,
.react-flow__resize-control.bottom.right * {
transition: none;
}
/* Small helper for monospace pre output */ /* Small helper for monospace pre output */
pre { pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, 'Roboto Mono', 'Courier New', monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, 'Roboto Mono', 'Courier New', monospace;