Add yaml compositino
This commit is contained in:
77
src/App.tsx
77
src/App.tsx
@@ -42,6 +42,10 @@ const initialEdges: Edge[] = [{ id: 'e-config-render', source: 'config-1', targe
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
|
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
|
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
|
||||||
|
const [rfInstance, setRfInstance] = React.useState<any | null>(null)
|
||||||
|
|
||||||
|
const wrapperRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
|
const [contextMenu, setContextMenu] = React.useState<null | { x: number; y: number; clientX: number; clientY: number }>(null)
|
||||||
|
|
||||||
const nodeTypes = React.useMemo(
|
const nodeTypes = React.useMemo(
|
||||||
() => ({ custom: CustomNode, config: ConfigNode, render: RenderingNode }),
|
() => ({ custom: CustomNode, config: ConfigNode, render: RenderingNode }),
|
||||||
@@ -53,8 +57,55 @@ export default function App() {
|
|||||||
[setEdges]
|
[setEdges]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onInit = React.useCallback((instance: any) => {
|
||||||
|
setRfInstance(instance)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const hideMenu = React.useCallback(() => setContextMenu(null), [])
|
||||||
|
|
||||||
|
const onCanvasContextMenu = React.useCallback((ev: React.MouseEvent) => {
|
||||||
|
ev.preventDefault()
|
||||||
|
const target = ev.target as HTMLElement
|
||||||
|
// don't show menu when right-clicking nodes or handles
|
||||||
|
if (target.closest('.react-flow__node') || target.closest('.react-flow__handle')) return
|
||||||
|
const rect = wrapperRef.current?.getBoundingClientRect()
|
||||||
|
const clientX = ev.clientX
|
||||||
|
const clientY = ev.clientY
|
||||||
|
const x = rect ? clientX - rect.left : ev.clientX
|
||||||
|
const y = rect ? clientY - rect.top : ev.clientY
|
||||||
|
setContextMenu({ x, y, clientX, clientY })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const createNode = React.useCallback(
|
||||||
|
(type: string) => {
|
||||||
|
if (!rfInstance) return
|
||||||
|
if (!contextMenu) return
|
||||||
|
const { clientX, clientY } = contextMenu
|
||||||
|
const rect = wrapperRef.current?.getBoundingClientRect()
|
||||||
|
const point = rect ? { x: clientX - rect.left, y: clientY - rect.top } : { x: clientX, y: clientY }
|
||||||
|
let position = point
|
||||||
|
try {
|
||||||
|
// project to flow coords when possible
|
||||||
|
position = rfInstance.project ? rfInstance.project(point) : point
|
||||||
|
} catch (e) {
|
||||||
|
// ignore and use raw point
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = genId()
|
||||||
|
const newNode: Node = {
|
||||||
|
id,
|
||||||
|
type: type === 'custom' ? 'custom' : type === 'config' ? 'config' : 'render',
|
||||||
|
position: { x: position.x, y: position.y },
|
||||||
|
data: type === 'config' ? { yaml: '# Enter YAML here\n', title: `config-${id}` } : {},
|
||||||
|
}
|
||||||
|
setNodes((nds) => nds.concat(newNode))
|
||||||
|
hideMenu()
|
||||||
|
},
|
||||||
|
[rfInstance, contextMenu, hideMenu, setNodes]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="reactflow-wrapper">
|
<div className="reactflow-wrapper" ref={wrapperRef} onContextMenu={onCanvasContextMenu} style={{ position: 'relative' }}>
|
||||||
<FlowContext.Provider value={{ nodes, setNodes, edges, setEdges }}>
|
<FlowContext.Provider value={{ nodes, setNodes, edges, setEdges }}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
@@ -64,11 +115,35 @@ export default function App() {
|
|||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
fitView
|
fitView
|
||||||
|
onInit={onInit}
|
||||||
>
|
>
|
||||||
<Background />
|
<Background />
|
||||||
<Controls />
|
<Controls />
|
||||||
<MiniMap />
|
<MiniMap />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
|
||||||
|
{contextMenu && (
|
||||||
|
<div
|
||||||
|
className="absolute bg-white border rounded shadow-md text-sm"
|
||||||
|
style={{ left: contextMenu.x, top: contextMenu.y, zIndex: 9999 }}
|
||||||
|
onMouseLeave={hideMenu}
|
||||||
|
>
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="text-xs text-gray-700 mb-2">Create node</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<button className="w-full text-left px-2 py-1 hover:bg-gray-100" onClick={() => createNode('config')}>
|
||||||
|
Configuration Node
|
||||||
|
</button>
|
||||||
|
<button className="w-full text-left px-2 py-1 hover:bg-gray-100" onClick={() => createNode('render')}>
|
||||||
|
Rendering Node
|
||||||
|
</button>
|
||||||
|
<button className="w-full text-left px-2 py-1 hover:bg-gray-100" onClick={() => createNode('custom')}>
|
||||||
|
Custom Node
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</FlowContext.Provider>
|
</FlowContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ export default function ConfigNode({ id, data }: Props) {
|
|||||||
const ctx = React.useContext(FlowContext)
|
const ctx = React.useContext(FlowContext)
|
||||||
const setNodes = ctx?.setNodes
|
const setNodes = ctx?.setNodes
|
||||||
const storedYaml = ctx?.nodes?.find((n: any) => n.id === id)?.data?.yaml ?? ''
|
const storedYaml = ctx?.nodes?.find((n: any) => n.id === id)?.data?.yaml ?? ''
|
||||||
|
const editorRef = React.useRef<any>(null)
|
||||||
|
const monacoRef = React.useRef<any>(null)
|
||||||
|
|
||||||
|
const incomingEdges = ctx?.edges?.filter((e: any) => e.target === id) ?? []
|
||||||
|
const incomingIds = incomingEdges.map((e: any) => e.source).sort()
|
||||||
|
const connectedConfigNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'config')
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// keep node data in sync on mount
|
// keep node data in sync on mount
|
||||||
@@ -40,6 +46,44 @@ export default function ConfigNode({ id, data }: Props) {
|
|||||||
[id, setNodes]
|
[id, setNodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleEditorMount = React.useCallback((editor: any, monaco: any) => {
|
||||||
|
editorRef.current = editor
|
||||||
|
monacoRef.current = monaco
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const insertIncludeFromNode = React.useCallback(
|
||||||
|
(sourceNode: any) => {
|
||||||
|
const ref = `${sourceNode.id}.yaml`
|
||||||
|
const includeText = `!include ${ref}\n`
|
||||||
|
const editor = editorRef.current
|
||||||
|
const monaco = monacoRef.current
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editor && monaco) {
|
||||||
|
const selection = editor.getSelection()
|
||||||
|
let range
|
||||||
|
if (selection) {
|
||||||
|
range = new monaco.Range(selection.startLineNumber, selection.startColumn, selection.startLineNumber, selection.startColumn)
|
||||||
|
} else {
|
||||||
|
const model = editor.getModel()
|
||||||
|
const lineCount = model.getLineCount()
|
||||||
|
range = new monaco.Range(lineCount + 1, 1, lineCount + 1, 1)
|
||||||
|
}
|
||||||
|
editor.executeEdits('insert-include', [{ range, text: includeText, forceMoveMarkers: true }])
|
||||||
|
const newVal = editor.getModel().getValue()
|
||||||
|
onChange(newVal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(value + '\n' + includeText)
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Insert include failed', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange, value]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-80 bg-white rounded shadow hover:shadow-md border border-gray-200">
|
<div className="w-80 bg-white rounded shadow hover:shadow-md border border-gray-200">
|
||||||
<div className="px-3 py-2 border-b border-gray-100 flex items-center justify-between">
|
<div className="px-3 py-2 border-b border-gray-100 flex items-center justify-between">
|
||||||
@@ -47,12 +91,34 @@ export default function ConfigNode({ id, data }: Props) {
|
|||||||
<div className="text-xs text-gray-400">YAML</div>
|
<div className="text-xs text-gray-400">YAML</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{connectedConfigNodes.length > 0 && (
|
||||||
|
<div className="px-3 py-2 text-xs bg-gray-50 border-b border-gray-100">
|
||||||
|
<div className="text-xs font-medium text-gray-600">Connected configs</div>
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
{connectedConfigNodes.map((n: any) => (
|
||||||
|
<div key={n.id} className="flex items-center justify-between">
|
||||||
|
<div className="text-xs text-gray-700">{n.data?.title ?? n.id}</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className="text-xs px-2 py-0.5 bg-white border rounded text-gray-600"
|
||||||
|
onClick={() => insertIncludeFromNode(n)}
|
||||||
|
>
|
||||||
|
Insert include
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ height: 180 }}>
|
<div style={{ height: 180 }}>
|
||||||
<Editor
|
<Editor
|
||||||
height="100%"
|
height="100%"
|
||||||
defaultLanguage="yaml"
|
defaultLanguage="yaml"
|
||||||
value={value}
|
value={value}
|
||||||
theme="vs-light"
|
theme="vs-light"
|
||||||
|
onMount={handleEditorMount}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
options={{ minimap: { enabled: false }, fontSize: 12 }}
|
options={{ minimap: { enabled: false }, fontSize: 12 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -36,11 +36,61 @@ export default function RenderingNode({ id }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = yaml.load(yamlText)
|
// resolve !include directives by inlining YAML from reachable config nodes
|
||||||
|
const resolveIncludes = (text: string, visited = new Set<string>()): string => {
|
||||||
|
const includeRegex = /^(\s*)!include\s+([^\s]+)\s*$/gm
|
||||||
|
return text.replace(includeRegex, (match, indent, ref) => {
|
||||||
|
const refName = ref.endsWith('.yaml') ? ref.slice(0, -5) : ref
|
||||||
|
// try to find a node by id or by title
|
||||||
|
const refNode = nodes.find((n: any) => n.id === refName || (n.data?.title === refName))
|
||||||
|
if (!refNode) {
|
||||||
|
throw new Error(`Included node not found: ${ref}`)
|
||||||
|
}
|
||||||
|
if (visited.has(refNode.id)) {
|
||||||
|
throw new Error(`Circular include detected: ${ref}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure refNode can reach this rendering node
|
||||||
|
const isReachable = (startId: string, targetId: string) => {
|
||||||
|
const q: string[] = [startId]
|
||||||
|
const seen = new Set<string>([startId])
|
||||||
|
while (q.length) {
|
||||||
|
const cur = q.shift()!
|
||||||
|
if (cur === targetId) return true
|
||||||
|
for (const e of edges) {
|
||||||
|
if (e.source === cur && !seen.has(e.target)) {
|
||||||
|
seen.add(e.target)
|
||||||
|
q.push(e.target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isReachable(refNode.id, id)) {
|
||||||
|
throw new Error(`Included node not connected to renderer: ${ref}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(refNode.id)
|
||||||
|
const includedRaw = String(refNode.data?.yaml ?? '')
|
||||||
|
const resolved = resolveIncludes(includedRaw, visited)
|
||||||
|
visited.delete(refNode.id)
|
||||||
|
|
||||||
|
// indent included content to match include position
|
||||||
|
const indented = resolved
|
||||||
|
.split('\n')
|
||||||
|
.map((line: string, idx: number) => (line === '' ? '' : indent + line))
|
||||||
|
.join('\n')
|
||||||
|
return indented
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedYaml = resolveIncludes(yamlText)
|
||||||
|
const parsed = yaml.load(resolvedYaml)
|
||||||
const newOutput = JSON.stringify(parsed, null, 2)
|
const newOutput = JSON.stringify(parsed, null, 2)
|
||||||
setOutput((prev) => (prev === newOutput ? prev : newOutput))
|
setOutput((prev) => (prev === newOutput ? prev : newOutput))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg = 'YAML parse error: ' + err.message
|
const msg = 'YAML parse/include error: ' + err.message
|
||||||
setOutput((prev) => (prev === msg ? prev : msg))
|
setOutput((prev) => (prev === msg ? prev : msg))
|
||||||
}
|
}
|
||||||
}, [id, incomingIds.join(','), yamlText, nodes.length, edges.length])
|
}, [id, incomingIds.join(','), yamlText, nodes.length, edges.length])
|
||||||
|
|||||||
Reference in New Issue
Block a user