From 48b9d2911431b72155263056edd549e2cca4aa93 Mon Sep 17 00:00:00 2001 From: dtoro Date: Thu, 5 Mar 2026 23:57:46 +0100 Subject: [PATCH] Add yaml compositino --- src/App.tsx | 77 +++++++++++++++++++++++++++++++- src/components/ConfigNode.tsx | 66 +++++++++++++++++++++++++++ src/components/RenderingNode.tsx | 54 +++++++++++++++++++++- 3 files changed, 194 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index bd0ee42..42d052b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,6 +42,10 @@ const initialEdges: Edge[] = [{ id: 'e-config-render', source: 'config-1', targe export default function App() { const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes) const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges) + const [rfInstance, setRfInstance] = React.useState(null) + + const wrapperRef = React.useRef(null) + const [contextMenu, setContextMenu] = React.useState(null) const nodeTypes = React.useMemo( () => ({ custom: CustomNode, config: ConfigNode, render: RenderingNode }), @@ -53,8 +57,55 @@ export default function App() { [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 ( -
+
+ + {contextMenu && ( +
+
+
Create node
+
+ + + +
+
+
+ )}
) diff --git a/src/components/ConfigNode.tsx b/src/components/ConfigNode.tsx index bd5ee93..b646267 100644 --- a/src/components/ConfigNode.tsx +++ b/src/components/ConfigNode.tsx @@ -17,6 +17,12 @@ export default function ConfigNode({ id, data }: Props) { const ctx = React.useContext(FlowContext) const setNodes = ctx?.setNodes const storedYaml = ctx?.nodes?.find((n: any) => n.id === id)?.data?.yaml ?? '' + const editorRef = React.useRef(null) + const monacoRef = React.useRef(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(() => { // keep node data in sync on mount @@ -40,6 +46,44 @@ export default function ConfigNode({ id, data }: Props) { [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 (
@@ -47,12 +91,34 @@ export default function ConfigNode({ id, data }: Props) {
YAML
+ {connectedConfigNodes.length > 0 && ( +
+
Connected configs
+
+ {connectedConfigNodes.map((n: any) => ( +
+
{n.data?.title ?? n.id}
+
+ +
+
+ ))} +
+
+ )} +
diff --git a/src/components/RenderingNode.tsx b/src/components/RenderingNode.tsx index 9146d54..2ecb290 100644 --- a/src/components/RenderingNode.tsx +++ b/src/components/RenderingNode.tsx @@ -36,11 +36,61 @@ export default function RenderingNode({ id }: Props) { } try { - const parsed = yaml.load(yamlText) + // resolve !include directives by inlining YAML from reachable config nodes + const resolveIncludes = (text: string, visited = new Set()): 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([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) setOutput((prev) => (prev === newOutput ? prev : newOutput)) } catch (err: any) { - const msg = 'YAML parse error: ' + err.message + const msg = 'YAML parse/include error: ' + err.message setOutput((prev) => (prev === msg ? prev : msg)) } }, [id, incomingIds.join(','), yamlText, nodes.length, edges.length])