This commit is contained in:
2026-03-07 10:43:26 +01:00
parent 7e42e74a1e
commit 59224880d2
7 changed files with 164 additions and 76 deletions

View File

@@ -39,18 +39,27 @@ const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1], y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
}) })
const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number }> = {
config: { width: 320, height: 320 },
render: { width: 384, height: 320 },
variable: { width: 224, height: 180 },
function: { width: 288, height: 260 },
}
const initialNodes: Node[] = [ const initialNodes: Node[] = [
{ {
id: 'config-1', id: 'config-1',
position: { x: 50, y: 50 }, position: { x: 50, y: 50 },
data: { plantuml: '@startuml\nactor User\nparticipant "Render" as R\nUser -> R : config\n@enduml\n' }, data: { plantuml: '@startuml\nactor User\nparticipant "Render" as R\nUser -> R : config\n@enduml\n' },
type: 'config', type: 'config',
style: DEFAULT_NODE_STYLE.config,
}, },
{ {
id: 'render-1', id: 'render-1',
position: { x: 350, y: 80 }, position: { x: 350, y: 80 },
data: {}, data: {},
type: 'render', type: 'render',
style: DEFAULT_NODE_STYLE.render,
}, },
].map((n) => ({ ...n, id: n.id ?? genId() })) ].map((n) => ({ ...n, id: n.id ?? genId() }))
@@ -133,11 +142,13 @@ export default function App() {
variable: { value: '', valueType: 'string' }, variable: { value: '', valueType: 'string' },
function: { body: '// args[0], args[1], ...\nreturn args[0];' }, function: { body: '// args[0], args[1], ...\nreturn args[0];' },
} }
const nodeType = typeMap[type] ?? 'config'
const newNode: Node = { const newNode: Node = {
id, id,
type: typeMap[type] ?? 'config', type: nodeType,
position: { x: position.x, y: position.y }, position: { x: position.x, y: position.y },
data: dataMap[type] ?? {}, data: dataMap[type] ?? {},
style: DEFAULT_NODE_STYLE[nodeType] ?? DEFAULT_NODE_STYLE.config,
} }
setNodes((nds) => nds.concat(newNode)) setNodes((nds) => nds.concat(newNode))
lastClickRef.current = null lastClickRef.current = null

View File

@@ -1,8 +1,31 @@
import type { ComponentProps } from "react"; import type { ComponentProps, ReactNode } from "react";
import { NodeResizeControl } from "@xyflow/react";
import { Expand } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function BaseNode({ className, ...props }: ComponentProps<"div">) { 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;
};
export function BaseNode({
className,
style,
resizable,
nodeId,
handles,
children,
...props
}: BaseNodeProps) {
const appliedStyle = style
? { ...style, display: "flex", flexDirection: "column" as const }
: undefined;
return ( return (
<div <div
className={cn( className={cn(
@@ -17,9 +40,35 @@ export function BaseNode({ className, ...props }: ComponentProps<"div">) {
"[.react-flow\\_\\_node.selected_&]:shadow-lg", "[.react-flow\\_\\_node.selected_&]:shadow-lg",
className, className,
)} )}
style={appliedStyle}
tabIndex={0} tabIndex={0}
{...props} {...props}
/> >
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">{children}</div>
{resizable && nodeId && (
<div
className="flex shrink-0 items-center justify-end border-t border-border bg-muted/30 px-2 py-1.5"
data-slot="base-node-resize-footer"
>
<NodeResizeControl
nodeId={nodeId}
position="bottom-right"
minWidth={120}
minHeight={80}
className="!border-0 !bg-transparent !rounded-none"
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}
</div>
); );
} }
@@ -35,7 +84,7 @@ export function BaseNodeHeader({
<header <header
{...props} {...props}
className={cn( className={cn(
"mx-0 my-0 -mb-1 flex flex-row items-center justify-between gap-2 px-3 py-2", "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 // Remove or modify these classes if you modify the padding in the
// `<BaseNode />` component. // `<BaseNode />` component.
className, className,
@@ -68,7 +117,7 @@ export function BaseNodeContent({
return ( return (
<div <div
data-slot="base-node-content" data-slot="base-node-content"
className={cn("flex flex-col gap-y-2 pt-1", className)} className={cn("min-h-0 flex-1 flex flex-col gap-y-2 overflow-auto pt-1", className)}
{...props} {...props}
/> />
); );
@@ -79,7 +128,7 @@ export function BaseNodeFooter({ className, ...props }: ComponentProps<"div">) {
<div <div
data-slot="base-node-footer" data-slot="base-node-footer"
className={cn( className={cn(
"flex flex-col items-center gap-y-2 border-t px-3 pt-2 pb-3", "shrink-0 flex flex-col items-center gap-y-2 border-t px-3 pt-2 pb-3",
className, className,
)} )}
{...props} {...props}

View File

@@ -1,6 +1,7 @@
import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import CodeMirror from '@uiw/react-codemirror' import CodeMirror from '@uiw/react-codemirror'
import FlowContext from '../../lib/flowContext' import FlowContext from '../../lib/flowContext'
import { useResizeHeight } from '../../hooks/useResizeHeight'
import { plantumlLanguage } from '../../lib/plantumlLanguage' import { plantumlLanguage } from '../../lib/plantumlLanguage'
import { useTheme } from '../../lib/themeContext' import { useTheme } from '../../lib/themeContext'
import { import {
@@ -26,9 +27,11 @@ import { InputHandle, OutputHandle } from './NodeHandles'
type Props = { type Props = {
id: string id: string
data: any data: any
width?: number
height?: number
} }
export const ConfigNode = memo(function ConfigNode({ id, data }: Props) { export const ConfigNode = memo(function ConfigNode({ id, data, width, height }: Props) {
const [value, setValue] = useState<string>(data?.plantuml ?? '@startuml\n\n@enduml\n') const [value, setValue] = useState<string>(data?.plantuml ?? '@startuml\n\n@enduml\n')
const { theme } = useTheme() const { theme } = useTheme()
const ctx = useContext(FlowContext) const ctx = useContext(FlowContext)
@@ -114,9 +117,10 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
) )
const extensions = useMemo(() => [plantumlLanguage.extension], []) const extensions = useMemo(() => [plantumlLanguage.extension], [])
const [editorHeight, editorContainerRef] = useResizeHeight(180)
return ( return (
<BaseNode className="w-80"> <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" /></>}>
<BaseNodeHeader className="border-b"> <BaseNodeHeader className="border-b">
<ScrollText className="size-4" /> <ScrollText className="size-4" />
<BaseNodeHeaderTitle>{id}.puml</BaseNodeHeaderTitle> <BaseNodeHeaderTitle>{id}.puml</BaseNodeHeaderTitle>
@@ -124,7 +128,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
<BaseNodeContent> <BaseNodeContent>
{hasDependencies && ( {hasDependencies && (
<div className="w-full"> <div className="shrink-0 w-full">
<Menubar className="h-auto bg-none p-1 border-none shadow-none"> <Menubar className="h-auto bg-none p-1 border-none shadow-none">
<MenubarMenu> <MenubarMenu>
<MenubarTrigger className="px-1.5 py-0 text-xs"> <MenubarTrigger className="px-1.5 py-0 text-xs">
@@ -194,12 +198,12 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
</div> </div>
)} )}
<div style={{ height: 180 }} className="w-full nodrag nopan overflow-hidden rounded border border-input"> <div ref={editorContainerRef} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden rounded border border-input">
<CodeMirror <CodeMirror
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules // @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
ref={editorRef} ref={editorRef}
value={value} value={value}
height="180px" height={`${editorHeight}px`}
theme={theme} theme={theme}
extensions={extensions} extensions={extensions}
onChange={onChange} onChange={onChange}
@@ -212,9 +216,6 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
<BaseNodeFooter> <BaseNodeFooter>
<div className="w-full text-xs text-muted-foreground">{storedPlantuml ? `${storedPlantuml.length} chars` : 'none'}</div> <div className="w-full text-xs text-muted-foreground">{storedPlantuml ? `${storedPlantuml.length} chars` : 'none'}</div>
</BaseNodeFooter> </BaseNodeFooter>
<InputHandle id="ain" />
<OutputHandle id="out" />
</BaseNode> </BaseNode>
) )
}) })

View File

@@ -2,6 +2,7 @@ import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useSt
import CodeMirror from '@uiw/react-codemirror' import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript' import { javascript } from '@codemirror/lang-javascript'
import FlowContext from '../../lib/flowContext' import FlowContext from '../../lib/flowContext'
import { useResizeHeight } from '../../hooks/useResizeHeight'
import { useTheme } from '../../lib/themeContext' import { useTheme } from '../../lib/themeContext'
import { import {
BaseNode, BaseNode,
@@ -16,13 +17,14 @@ import { Code2 } from 'lucide-react'
type Props = { type Props = {
id: string id: string
data: { body?: string } data: { body?: string }
style?: React.CSSProperties
} }
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)
return args[0]; return args[0];
` `
export const FunctionNode = memo(function FunctionNode({ id, data }: Props) { export const FunctionNode = memo(function FunctionNode({ id, data, width, height }: Props) {
const [value, setValue] = useState<string>(data?.body ?? DEFAULT_BODY) const [value, setValue] = useState<string>(data?.body ?? DEFAULT_BODY)
const { theme } = useTheme() const { theme } = useTheme()
const ctx = useContext(FlowContext) const ctx = useContext(FlowContext)
@@ -52,22 +54,23 @@ export const FunctionNode = memo(function FunctionNode({ id, data }: Props) {
const storedBody = ctx?.nodes?.find((n: any) => n.id === id)?.data?.body ?? '' const storedBody = ctx?.nodes?.find((n: any) => n.id === id)?.data?.body ?? ''
const extensions = useMemo(() => [javascript()], []) const extensions = useMemo(() => [javascript()], [])
const [editorHeight, editorContainerRef] = useResizeHeight(120)
return ( return (
<BaseNode className="w-72"> <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" /></>}>
<BaseNodeHeader className="border-b"> <BaseNodeHeader className="border-b">
<Code2 className="size-4" /> <Code2 className="size-4" />
<BaseNodeHeaderTitle>{id}</BaseNodeHeaderTitle> <BaseNodeHeaderTitle>{id}</BaseNodeHeaderTitle>
</BaseNodeHeader> </BaseNodeHeader>
<BaseNodeContent> <BaseNodeContent>
<p className="text-[10px] text-muted-foreground mb-1"> <p className="shrink-0 text-[10px] text-muted-foreground mb-1">
Call in config: <code className="rounded bg-muted px-0.5">$&#123;{id}(var1, var2)&#125;</code> Call in config: <code className="rounded bg-muted px-0.5">$&#123;{id}(var1, var2)&#125;</code>
</p> </p>
<div style={{ height: 120 }} className="w-full nodrag nopan overflow-hidden rounded border border-input"> <div ref={editorContainerRef} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden rounded border border-input">
<CodeMirror <CodeMirror
value={value} value={value}
height="120px" height={`${editorHeight}px`}
theme={theme} theme={theme}
extensions={extensions} extensions={extensions}
onChange={onChange} onChange={onChange}
@@ -82,9 +85,6 @@ export const FunctionNode = memo(function FunctionNode({ id, data }: Props) {
Body: {storedBody ? `${storedBody.length} chars` : 'none'} Body: {storedBody ? `${storedBody.length} chars` : 'none'}
</div> </div>
</BaseNodeFooter> </BaseNodeFooter>
<InputHandle id="in" />
<OutputHandle id="out" />
</BaseNode> </BaseNode>
) )
}) })

View File

@@ -18,8 +18,11 @@ const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
type Props = { type Props = {
id: string id: string
data?: any data?: any
style?: React.CSSProperties
} }
const DEFAULT_CONFIG_NODE_STYLE = { width: 320, height: 320 }
const DARK_SKINPARAMS = ` const DARK_SKINPARAMS = `
skinparam backgroundColor #1e1e1e skinparam backgroundColor #1e1e1e
skinparam defaultFontColor #e0e0e0 skinparam defaultFontColor #e0e0e0
@@ -63,7 +66,7 @@ skinparam class {
} }
` `
export const RenderingNode = memo(function RenderingNode({ id }: Props) { export const RenderingNode = memo(function RenderingNode({ id, width, height }: Props) {
const [svgContent, setSvgContent] = useState<string | null>(null) const [svgContent, setSvgContent] = useState<string | null>(null)
const [error, setError] = useState<null | { kind: string; message: string }>(null) const [error, setError] = useState<null | { kind: string; message: string }>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -264,58 +267,60 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
}, [id, theme, plantumlText, configSignature, edgesSignature, variablesSignature, functionsSignature]) }, [id, theme, plantumlText, configSignature, edgesSignature, variablesSignature, functionsSignature])
return ( return (
<BaseNode className="w-96"> <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" /></>}>
<BaseNodeHeader className="border-b"> <BaseNodeHeader className="border-b">
<Sparkles className="size-4" /> <Sparkles className="size-4" />
<BaseNodeHeaderTitle>{id}</BaseNodeHeaderTitle> <BaseNodeHeaderTitle>{id}</BaseNodeHeaderTitle>
</BaseNodeHeader> </BaseNodeHeader>
<BaseNodeContent> <BaseNodeContent>
{incomingIds.length === 0 ? ( <div className="min-h-0 flex-1 flex flex-col">
<Empty> {incomingIds.length === 0 ? (
<EmptyHeader> <Empty className="min-h-0 flex-1">
<EmptyMedia variant="icon"> <EmptyHeader>
<Sparkles className="size-6" /> <EmptyMedia variant="icon">
</EmptyMedia> <Sparkles className="size-6" />
<EmptyTitle>No configuration connected</EmptyTitle> </EmptyMedia>
<EmptyDescription>Connect a Configuration node or create one. The renderer will display the PlantUML diagram.</EmptyDescription> <EmptyTitle>No configuration connected</EmptyTitle>
</EmptyHeader> <EmptyDescription>Connect a Configuration node or create one. The renderer will display the PlantUML diagram.</EmptyDescription>
<EmptyContent> </EmptyHeader>
<button <EmptyContent>
className="inline-flex items-center rounded bg-primary px-3 py-1 text-xs text-primary-foreground" <button
onClick={() => { className="inline-flex items-center rounded bg-primary px-3 py-1 text-xs text-primary-foreground"
if (!setNodes || !setEdges) return onClick={() => {
const genId = () => `node_${Math.random().toString(36).slice(2, 9)}` if (!setNodes || !setEdges) return
const nid = genId() const genId = () => `node_${Math.random().toString(36).slice(2, 9)}`
const thisNode = nodes.find((n: any) => n.id === id) const nid = genId()
const pos = thisNode?.position ?? { x: 0, y: 0 } const thisNode = nodes.find((n: any) => n.id === id)
const newPos = { x: pos.x - 220, y: pos.y } const pos = thisNode?.position ?? { x: 0, y: 0 }
const newNode = { id: nid, type: 'config', position: newPos, data: { plantuml: '@startuml\n\n@enduml\n', title: `config-${nid}` } } const newPos = { x: pos.x - 220, y: pos.y }
setNodes((nds: any[]) => nds.concat(newNode)) const newNode = { id: nid, type: 'config', position: newPos, data: { plantuml: '@startuml\n\n@enduml\n', title: `config-${nid}` }, style: DEFAULT_CONFIG_NODE_STYLE }
const edgeId = `e-${nid}-${id}` setNodes((nds: any[]) => nds.concat(newNode))
setEdges((eds: any[]) => eds.concat({ id: edgeId, source: nid, target: id })) const edgeId = `e-${nid}-${id}`
}} setEdges((eds: any[]) => eds.concat({ id: edgeId, source: nid, target: id }))
> }}
Create Config >
</button> Create Config
</EmptyContent> </button>
</Empty> </EmptyContent>
) : error ? ( </Empty>
srcData?.renderError ? ( ) : error ? (
srcData.renderError(error) srcData?.renderError ? (
) : srcData?.errorHtml ? ( srcData.renderError(error)
<div className="p-3 text-xs text-red-700 dark:text-red-400" dangerouslySetInnerHTML={{ __html: String(srcData.errorHtml) }} /> ) : srcData?.errorHtml ? (
) : ( <div className="p-3 text-xs text-red-700 dark:text-red-400" dangerouslySetInnerHTML={{ __html: String(srcData.errorHtml) }} />
<div className="p-3 text-xs text-red-700 dark:text-red-400">{error.message}</div> ) : (
) <div className="p-3 text-xs text-red-700 dark:text-red-400">{error.message}</div>
) : loading ? ( )
<div className="flex min-h-[120px] items-center justify-center text-xs text-muted-foreground">Rendering</div> ) : loading ? (
) : svgContent ? ( <div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering</div>
<div ) : svgContent ? (
className="min-h-[120px] w-full overflow-auto rounded border border-input bg-white dark:bg-gray-900 [&_svg]:max-w-full [&_svg]:h-auto" <div
dangerouslySetInnerHTML={{ __html: svgContent }} className="min-h-0 flex-1 w-full overflow-auto rounded border border-input bg-white dark:bg-gray-900 [&_svg]:max-w-full [&_svg]:h-auto"
/> dangerouslySetInnerHTML={{ __html: svgContent }}
) : null} />
) : null}
</div>
</BaseNodeContent> </BaseNodeContent>
<BaseNodeFooter> <BaseNodeFooter>
@@ -323,9 +328,6 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
{svgContent ? 'PlantUML diagram' : error ? 'Error' : '—'} {svgContent ? 'PlantUML diagram' : error ? 'Error' : '—'}
</div> </div>
</BaseNodeFooter> </BaseNodeFooter>
<InputHandle id="ain" />
<OutputHandle id="out" />
</BaseNode> </BaseNode>
) )
}) })

View File

@@ -88,7 +88,7 @@ export const VariableNode = memo(function VariableNode({ id, data }: Props) {
) )
return ( return (
<BaseNode className="w-56"> <BaseNode className="min-w-56 min-h-[180px]" handles={<OutputHandle id="out" />}>
<BaseNodeHeader className="border-b"> <BaseNodeHeader className="border-b">
<Variable className="size-4" /> <Variable className="size-4" />
<BaseNodeHeaderTitle>{id}</BaseNodeHeaderTitle> <BaseNodeHeaderTitle>{id}</BaseNodeHeaderTitle>
@@ -130,8 +130,6 @@ export const VariableNode = memo(function VariableNode({ id, data }: Props) {
Use in config: <code className="rounded bg-muted px-0.5">prop: $&#123;{id}&#125;</code> Use in config: <code className="rounded bg-muted px-0.5">prop: $&#123;{id}&#125;</code>
</p> </p>
</BaseNodeContent> </BaseNodeContent>
<OutputHandle id="out" />
</BaseNode> </BaseNode>
) )
}) })

View File

@@ -0,0 +1,27 @@
import { useEffect, useRef, useState } from 'react'
/**
* Returns the height of the observed element, updating when it resizes (e.g. node resize).
* Used to give CodeMirror and other components an explicit height that tracks their container.
*/
export function useResizeHeight(defaultHeight: number): [number, React.RefObject<HTMLDivElement | null>] {
const ref = useRef<HTMLDivElement | null>(null)
const [height, setHeight] = useState(defaultHeight)
useEffect(() => {
const el = ref.current
if (!el) return
const ro = new ResizeObserver((entries) => {
const entry = entries[0]
if (entry?.contentRect.height != null && entry.contentRect.height > 0) {
setHeight(entry.contentRect.height)
}
})
ro.observe(el)
setHeight(el.getBoundingClientRect().height)
return () => ro.disconnect()
}, [])
return [height, ref]
}