This commit is contained in:
2026-03-09 16:08:56 +01:00
parent c55bde60e6
commit 40379161e0
17 changed files with 128 additions and 360 deletions

View File

@@ -12,6 +12,8 @@ export type BaseNodeProps = ComponentProps<"div"> & {
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;
};
export function BaseNode({
@@ -22,6 +24,7 @@ export function BaseNode({
nodeId,
handles,
children,
selected,
...props
}: BaseNodeProps) {
const hasSize =
@@ -48,15 +51,10 @@ export function BaseNode({
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",
selected && "border-primary shadow-[0_0_0_2px_hsl(var(--primary)_/_0.4)]",
className,
)}
data-selected={selected}
style={appliedStyle}
tabIndex={0}
{...props}

View File

@@ -47,7 +47,7 @@ export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; titl
type Props = AbstractNodeProps<ConfigNodeData>
function ConfigNodeComponent({ id, data, width, height }: Props) {
function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
const configTypeId = getConfigTypeId(data ?? {})
const configType = getConfigType(configTypeId)
const content = getConfigContent(data ?? {})
@@ -232,7 +232,7 @@ function ConfigNodeComponent({ id, data, width, height }: Props) {
: undefined
return (
<BaseNode className="min-w-80 min-h-[280px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="ain" nodeId={id} /><OutputHandle id="out" /></>}>
<BaseNode className="min-w-80 min-h-[280px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<><InputHandle id="ain" nodeId={id} /><OutputHandle id="out" /></>}>
<BaseNodeHeaderRow
icon={<ScrollText className="size-4" />}
title={<NodeHeaderTitle nodeId={id} displayTitle={`${id}`} />}

View File

@@ -27,7 +27,7 @@ export type FunctionNodeData = { body?: string }
type Props = AbstractNodeProps<FunctionNodeData>
function FunctionNodeComponent({ id, data, width, height }: Props) {
function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
const bodyValue = data?.body ?? ''
const { theme } = useTheme()
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
@@ -91,7 +91,7 @@ function FunctionNodeComponent({ id, data, width, height }: Props) {
: undefined
return (
<BaseNode className="min-w-72 min-h-[260px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="in" nodeId={id} /><OutputHandle id="out" /></>}>
<BaseNode className="min-w-72 min-h-[260px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<><InputHandle id="in" nodeId={id} /><OutputHandle id="out" /></>}>
<BaseNodeHeaderRow icon={<Code2 className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
<BaseNodeContent>

View File

@@ -36,7 +36,7 @@ const DEFAULT_VIEWPORT_HEIGHT = 800
type Props = AbstractNodeProps<RenderingNodeData>
function RenderingNodeComponent({ id, data, width, height }: Props) {
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const [renderedContent, setRenderedContent] = useState<string | null>(null)
const [error, setError] = useState<null | { kind: string; message: string }>(null)
const [loading, setLoading] = useState(false)
@@ -529,6 +529,31 @@ function RenderingNodeComponent({ id, data, width, height }: Props) {
img.src = dataUrl
}, [id, renderedContent, isSvgOutput])
const copyPng = useCallback(() => {
if (!renderedContent || !isSvgOutput) return
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(renderedContent)
const img = new Image()
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.drawImage(img, 0, 0)
canvas.toBlob((blob) => {
if (blob) navigator.clipboard?.write([new ClipboardItem({ 'image/png': blob })]).catch(() => {})
}, 'image/png')
}
img.onerror = () => {}
img.src = dataUrl
}, [renderedContent, isSvgOutput])
const copySvg = useCallback(() => {
if (!renderedContent || !isSvgOutput) return
const blob = new Blob([renderedContent], { type: 'image/svg+xml' })
navigator.clipboard?.write([new ClipboardItem({ 'image/svg+xml': blob })]).catch(() => {})
}, [renderedContent, isSvgOutput])
const status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial'
const [viewportDraft, setViewportDraft] = useState({ width: viewportWidth, height: viewportHeight })
@@ -545,7 +570,7 @@ function RenderingNodeComponent({ id, data, width, height }: Props) {
return (
<NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}>
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} handles={<InputHandle id="ain" nodeId={id} />}>
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<InputHandle id="ain" nodeId={id} />}>
<BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
<BaseNodeContent>
@@ -602,14 +627,20 @@ function RenderingNodeComponent({ id, data, width, height }: Props) {
<MenubarSub>
<MenubarSeparator />
<MenubarSubTrigger className="text-xs" disabled={!isSvgOutput}>
Export
Export / Copy
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[10rem]">
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!isSvgOutput}>
PNG
</MenubarItem>
<MenubarSubContent className="min-w-[10rem]" aria-label="Export or copy diagram">
<MenubarItem className="text-xs" onClick={downloadSvg} disabled={!isSvgOutput}>
SVG
Download SVG
</MenubarItem>
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!isSvgOutput}>
Download PNG
</MenubarItem>
<MenubarItem className="text-xs" onClick={copySvg} disabled={!isSvgOutput}>
Copy SVG
</MenubarItem>
<MenubarItem className="text-xs" onClick={copyPng} disabled={!isSvgOutput}>
Copy image
</MenubarItem>
</MenubarSubContent>
</MenubarSub>

View File

@@ -47,7 +47,7 @@ function coerceValue(raw: string, valueType: ValueType): string | number | boole
}
}
function VariableNodeComponent({ id, data }: Props) {
function VariableNodeComponent({ id, data, selected }: Props) {
const { updateData } = useAbstractNode<VariableNodeData>(id, data ?? {})
const valueType: ValueType = data?.valueType ?? 'string'
@@ -79,7 +79,7 @@ function VariableNodeComponent({ id, data }: Props) {
)
return (
<BaseNode className="min-w-56 min-h-[180px]" handles={<OutputHandle id="out" />}>
<BaseNode className="min-w-56 min-h-[180px]" selected={selected} handles={<OutputHandle id="out" />}>
<BaseNodeHeaderRow icon={<Variable className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
<BaseNodeContent>