Compare commits
2 Commits
7e42e74a1e
...
6572052dba
| Author | SHA1 | Date | |
|---|---|---|---|
| 6572052dba | |||
| 59224880d2 |
13
src/App.tsx
13
src/App.tsx
@@ -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
|
||||||
|
|||||||
@@ -1,8 +1,48 @@
|
|||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
|
import { NodeResizeControl } from "@xyflow/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;
|
||||||
|
/** 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({
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
dimensions,
|
||||||
|
resizable,
|
||||||
|
nodeId,
|
||||||
|
handles,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: BaseNodeProps) {
|
||||||
|
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;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -17,9 +57,38 @@ 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"
|
||||||
|
style={{ contain: "layout" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{resizable && nodeId && (
|
||||||
|
<NodeResizeControl
|
||||||
|
nodeId={nodeId}
|
||||||
|
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" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
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 corner to resize"
|
||||||
|
aria-label="Resize node"
|
||||||
/>
|
/>
|
||||||
|
</NodeResizeControl>
|
||||||
|
)}
|
||||||
|
{handles}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +104,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,
|
||||||
@@ -61,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
|
||||||
@@ -68,7 +184,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 +195,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}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
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 {
|
||||||
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 {
|
||||||
@@ -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,17 +117,20 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const extensions = useMemo(() => [plantumlLanguage.extension], [])
|
const extensions = useMemo(() => [plantumlLanguage.extension], [])
|
||||||
|
const [editorHeight, editorContainerRef] = useResizeHeight(180)
|
||||||
|
|
||||||
|
const dimensions =
|
||||||
|
width != null && height != null && width > 0 && height > 0
|
||||||
|
? { width, height }
|
||||||
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNode className="w-80">
|
<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 && (
|
||||||
<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 +200,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}
|
||||||
@@ -210,11 +216,8 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
|
|||||||
</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>
|
||||||
|
|
||||||
<InputHandle id="ain" />
|
|
||||||
<OutputHandle id="out" />
|
|
||||||
</BaseNode>
|
</BaseNode>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ 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,
|
||||||
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'
|
||||||
@@ -16,13 +17,15 @@ import { Code2 } from 'lucide-react'
|
|||||||
type Props = {
|
type Props = {
|
||||||
id: string
|
id: string
|
||||||
data: { body?: string }
|
data: { body?: string }
|
||||||
|
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)
|
||||||
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 +55,24 @@ 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)
|
||||||
|
const dimensions =
|
||||||
|
width != null && height != null && width > 0 && height > 0
|
||||||
|
? { width, height }
|
||||||
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNode className="w-72">
|
<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="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">${{id}(var1, var2)}</code>
|
Call in config: <code className="rounded bg-muted px-0.5">${{id}(var1, var2)}</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}
|
||||||
@@ -78,13 +83,8 @@ export const FunctionNode = memo(function FunctionNode({ id, data }: Props) {
|
|||||||
</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>
|
||||||
|
|
||||||
<InputHandle id="in" />
|
|
||||||
<OutputHandle id="out" />
|
|
||||||
</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'
|
||||||
@@ -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)
|
||||||
@@ -263,16 +266,19 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
|
|||||||
// 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="w-96">
|
<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">
|
||||||
{incomingIds.length === 0 ? (
|
{incomingIds.length === 0 ? (
|
||||||
<Empty>
|
<Empty className="min-h-0 flex-1">
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyMedia variant="icon">
|
<EmptyMedia variant="icon">
|
||||||
<Sparkles className="size-6" />
|
<Sparkles className="size-6" />
|
||||||
@@ -290,7 +296,7 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
|
|||||||
const thisNode = nodes.find((n: any) => n.id === id)
|
const thisNode = nodes.find((n: any) => n.id === id)
|
||||||
const pos = thisNode?.position ?? { x: 0, y: 0 }
|
const pos = thisNode?.position ?? { x: 0, y: 0 }
|
||||||
const newPos = { x: pos.x - 220, y: pos.y }
|
const newPos = { x: pos.x - 220, y: pos.y }
|
||||||
const newNode = { id: nid, type: 'config', position: newPos, data: { plantuml: '@startuml\n\n@enduml\n', title: `config-${nid}` } }
|
const newNode = { id: nid, type: 'config', position: newPos, data: { plantuml: '@startuml\n\n@enduml\n', title: `config-${nid}` }, style: DEFAULT_CONFIG_NODE_STYLE }
|
||||||
setNodes((nds: any[]) => nds.concat(newNode))
|
setNodes((nds: any[]) => nds.concat(newNode))
|
||||||
const edgeId = `e-${nid}-${id}`
|
const edgeId = `e-${nid}-${id}`
|
||||||
setEdges((eds: any[]) => eds.concat({ id: edgeId, source: nid, target: id }))
|
setEdges((eds: any[]) => eds.concat({ id: edgeId, source: nid, target: id }))
|
||||||
@@ -309,23 +315,21 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
|
|||||||
<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 ? (
|
) : loading ? (
|
||||||
<div className="flex min-h-[120px] items-center justify-center text-xs text-muted-foreground">Rendering…</div>
|
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering…</div>
|
||||||
) : svgContent ? (
|
) : svgContent ? (
|
||||||
<div
|
<div
|
||||||
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"
|
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 }}
|
dangerouslySetInnerHTML={{ __html: svgContent }}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<InputHandle id="ain" />
|
|
||||||
<OutputHandle id="out" />
|
|
||||||
</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'
|
||||||
@@ -88,11 +89,8 @@ 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">
|
<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,12 +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>
|
||||||
|
|
||||||
<OutputHandle id="out" />
|
<BaseNodeFooter>
|
||||||
|
<BaseNodeFooterText>
|
||||||
|
Use in config: <code className="rounded bg-muted px-0.5">prop: {'${'}{id}{'}'}</code>
|
||||||
|
</BaseNodeFooterText>
|
||||||
|
</BaseNodeFooter>
|
||||||
</BaseNode>
|
</BaseNode>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
27
src/hooks/useResizeHeight.ts
Normal file
27
src/hooks/useResizeHeight.ts
Normal 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]
|
||||||
|
}
|
||||||
@@ -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