improve drag
This commit is contained in:
@@ -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,18 +10,36 @@ 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 =
|
||||||
|
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 }
|
? { ...style, display: "flex", flexDirection: "column" as const }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
{resizable && nodeId && (
|
|
||||||
<div
|
<div
|
||||||
className="flex shrink-0 items-center justify-end border-t border-border bg-muted/30 px-2 py-1.5"
|
className="min-h-0 flex-1 flex flex-col overflow-hidden"
|
||||||
data-slot="base-node-resize-footer"
|
style={{ contain: "layout" }}
|
||||||
>
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{resizable && nodeId && (
|
||||||
<NodeResizeControl
|
<NodeResizeControl
|
||||||
nodeId={nodeId}
|
nodeId={nodeId}
|
||||||
position="bottom-right"
|
position="bottom-right"
|
||||||
minWidth={120}
|
minWidth={120}
|
||||||
minHeight={80}
|
minHeight={80}
|
||||||
className="!border-0 !bg-transparent !rounded-none"
|
className={cn(
|
||||||
style={{ padding: 0 }}
|
"!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" }}
|
||||||
>
|
>
|
||||||
<span
|
<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"
|
className="absolute bottom-0 right-0 w-8 h-8 rounded-br border-l border-t border-border/30 hover:border-border/50"
|
||||||
title="Drag to resize"
|
title="Drag corner to resize"
|
||||||
>
|
aria-label="Resize node"
|
||||||
<Expand className="size-3.5" />
|
/>
|
||||||
</span>
|
|
||||||
</NodeResizeControl>
|
</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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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: ${{id}}</code>
|
|
||||||
</p>
|
|
||||||
</BaseNodeContent>
|
</BaseNodeContent>
|
||||||
|
|
||||||
|
<BaseNodeFooter>
|
||||||
|
<BaseNodeFooterText>
|
||||||
|
Use in config: <code className="rounded bg-muted px-0.5">prop: {'${'}{id}{'}'}</code>
|
||||||
|
</BaseNodeFooterText>
|
||||||
|
</BaseNodeFooter>
|
||||||
</BaseNode>
|
</BaseNode>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user