diff --git a/frontend/src/app/canvas/CanvasPage.tsx b/frontend/src/app/canvas/CanvasPage.tsx index acb230f..46323a6 100644 --- a/frontend/src/app/canvas/CanvasPage.tsx +++ b/frontend/src/app/canvas/CanvasPage.tsx @@ -217,9 +217,12 @@ export function CanvasPage({ projectId: _projectId }: CanvasPageProps) { ) const isValidConnection = useCallback( - (connection: Connection) => { - const sourceNode = nodes.find((n) => n.id === connection.source) - const targetNode = nodes.find((n) => n.id === connection.target) + (connection: Connection | AppEdge) => { + const src = 'source' in connection ? connection.source : undefined + const tgt = 'target' in connection ? connection.target : undefined + if (typeof src !== 'string' || typeof tgt !== 'string') return false + const sourceNode = nodes.find((n) => n.id === src) + const targetNode = nodes.find((n) => n.id === tgt) const sourceType = sourceNode?.type const targetType = targetNode?.type if (!sourceType || !targetType) return false @@ -227,13 +230,16 @@ export function CanvasPage({ projectId: _projectId }: CanvasPageProps) { const targetData = targetNode?.data as { configType?: string } | undefined if (targetData?.configType !== 'markdown') return false } - return isConnectionAllowed(sourceType, targetType, connection.source, connection.target) + return isConnectionAllowed(sourceType, targetType, src, tgt) }, [nodes] ) const onConnectStart = useCallback( - (_: React.MouseEvent | React.TouchEvent, params: { nodeId?: string | null; handleId?: string | null; handleType?: string | null }) => { + ( + _: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent, + params: { nodeId?: string | null; handleId?: string | null; handleType?: string | null } + ) => { if (params.handleType !== 'source' || !params.nodeId) { setConnectionFrom(null) return @@ -371,14 +377,14 @@ export function CanvasPage({ projectId: _projectId }: CanvasPageProps) { const createNode = useCallback( (type: string) => { const position = getMenuPosition() - if (position == null) return - const nodeType = type as Node['type'] - const newId = getNextNodeId(nodeType, nodesRef.current.map((n) => n.id)) - const dataMap = getDefaultDataForType(nodeType, newId) - const style = getDefaultStyle(nodeType) + if (position == null || typeof type !== 'string') return + const existingIds = nodesRef.current.map((n) => n.id).filter((id): id is string => id != null) + const newId = getNextNodeId(type, existingIds) + const dataMap = getDefaultDataForType(type, newId) + const style = getDefaultStyle(type) const newNode: Node = { id: newId, - type: nodeType, + type: type as Node['type'], position: { x: position.x, y: position.y }, data: dataMap, style, @@ -416,14 +422,16 @@ export function CanvasPage({ projectId: _projectId }: CanvasPageProps) { const raw = JSON.parse(text) as { id?: string; type?: string; data?: Record; position?: { x: number; y: number }; style?: unknown } const validIds = getRegisteredNodeTypeIds() if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return + const nodeType = raw.type setNodes((nds) => { - const newId = getNextNodeId(raw.type as Node['type'], nds.map((n) => n.id)) + const existingIds = nds.map((n) => n.id).filter((id): id is string => id != null) + const newId = getNextNodeId(nodeType, existingIds) const data = raw.data != null && typeof raw.data === 'object' ? { ...raw.data } : {} - if (raw.type === 'config' && data && 'title' in data) data.title = `config-${newId}` - const style = getDefaultStyle(raw.type) + if (nodeType === 'config' && data && 'title' in data) data.title = `config-${newId}` + const style = getDefaultStyle(nodeType) const newNode: Node = { id: newId, - type: raw.type as Node['type'], + type: nodeType as Node['type'], position: { x: position.x, y: position.y }, data, style, @@ -560,7 +568,8 @@ export function CanvasPage({ projectId: _projectId }: CanvasPageProps) { nodesConnectable elementsSelectable > - + {/* BackgroundVariant from @xyflow/system expects enum; 'dots' is valid at runtime */} + ['variant']} gap={20} />
diff --git a/frontend/src/components/base/AnimatedEdge.tsx b/frontend/src/components/base/AnimatedEdge.tsx index 9fa9cee..67b6bbc 100644 --- a/frontend/src/components/base/AnimatedEdge.tsx +++ b/frontend/src/components/base/AnimatedEdge.tsx @@ -27,7 +27,7 @@ export function AnimatedEdge({ const nodes = ctx?.nodes ?? [] const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target]) const derivedLabel = useMemo( - () => getConnectionLabelForTarget(targetNode?.type), + () => (targetNode?.type != null ? getConnectionLabelForTarget(targetNode.type) : undefined), [targetNode?.type] ) const label = labelProp ?? derivedLabel diff --git a/frontend/src/components/base/BaseHandle.tsx b/frontend/src/components/base/BaseHandle.tsx deleted file mode 100644 index 33ac7c9..0000000 --- a/frontend/src/components/base/BaseHandle.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { ComponentProps } from "react"; -import { Handle, type HandleProps } from "@xyflow/react"; - -import { cn } from "@/lib/utils"; - -export type BaseHandleProps = HandleProps; - -export function BaseHandle({ - className, - children, - ...props -}: ComponentProps) { - return ( - - {children} - - ); -} diff --git a/frontend/src/components/base/FlowKeyboardShortcuts.tsx b/frontend/src/components/base/FlowKeyboardShortcuts.tsx index 66e1296..0d078a6 100644 --- a/frontend/src/components/base/FlowKeyboardShortcuts.tsx +++ b/frontend/src/components/base/FlowKeyboardShortcuts.tsx @@ -34,6 +34,7 @@ export function FlowKeyboardShortcuts() { } const validIds = getRegisteredNodeTypeIds() if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return + const nodeType = raw.type const pane = document.querySelector('.react-flow__viewport') const rect = pane?.getBoundingClientRect() const center = rect @@ -41,13 +42,14 @@ export function FlowKeyboardShortcuts() { : { x: window.innerWidth / 2, y: window.innerHeight / 2 } const position = screenToFlowPosition(center) setNodes((nds: Node[]) => { - const newId = getNextNodeId(raw.type, nds.map((n) => n.id)) + const existingIds = nds.map((n) => n.id).filter((id): id is string => id != null) + const newId = getNextNodeId(nodeType, existingIds) const data: Record = raw.data != null && typeof raw.data === 'object' ? { ...raw.data } - : (getDefaultDataForType(raw.type, newId) as Record) - if (raw.type === 'config') data.title = `config-${newId}` - const style = getDefaultStyle(raw.type) + : (getDefaultDataForType(nodeType, newId) as Record) + if (nodeType === 'config') data.title = `config-${newId}` + const style = getDefaultStyle(nodeType) const newNode: Node = { id: newId, type: raw.type as Node['type'], diff --git a/frontend/src/components/base/NodeHandles.tsx b/frontend/src/components/base/NodeHandles.tsx index ebf2a60..e493113 100644 --- a/frontend/src/components/base/NodeHandles.tsx +++ b/frontend/src/components/base/NodeHandles.tsx @@ -19,7 +19,7 @@ export function InputHandle({ id, nodeId }: NodeHandleProps) { isConnecting && isValidConnection?.({ source: connectionFrom!.nodeId, - sourceHandle: connectionFrom!.sourceHandle ?? undefined, + sourceHandle: connectionFrom!.sourceHandle ?? null, target: nodeId!, targetHandle: id, }) diff --git a/frontend/src/components/base/NodeHeaderTitle.tsx b/frontend/src/components/base/NodeHeaderTitle.tsx index 053bc94..5bdf195 100644 --- a/frontend/src/components/base/NodeHeaderTitle.tsx +++ b/frontend/src/components/base/NodeHeaderTitle.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useContext, useEffect, useRef, useState } from 'react' import FlowContext from '../../lib/flowContext' +import type { AppNode } from '../../lib/nodeTypes' import { replaceNodeIdInGraph } from '../../lib/flowUtils' type Props = { @@ -41,7 +42,7 @@ export function NodeHeaderTitle({ nodeId, displayTitle }: Props) { return } const { nodes: nextNodes, edges: nextEdges } = replaceNodeIdInGraph(nodes, edges, nodeId, newId) - setNodes(nextNodes) + setNodes(nextNodes as AppNode[]) setEdges(nextEdges) setRenamingNodeId(null) }, [nodeId, inputValue, nodes, edges, setNodes, setEdges, setRenamingNodeId]) diff --git a/frontend/src/components/base/NodeMenubar.tsx b/frontend/src/components/base/NodeMenubar.tsx index d6b4743..ab91205 100644 --- a/frontend/src/components/base/NodeMenubar.tsx +++ b/frontend/src/components/base/NodeMenubar.tsx @@ -60,7 +60,9 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon data: typeof node.data === 'object' && node.data !== null ? { ...node.data } : node.data, style: getDefaultStyle(nodeType), } - if (newNode.data?.title && nodeType === 'config') newNode.data.title = `${newId}` + if (nodeType === 'config' && newNode.data && typeof newNode.data === 'object' && 'title' in newNode.data) { + (newNode.data as { title: string }).title = `${newId}` + } return nds.concat(newNode) }) }, [node, nodeType, setNodes]) diff --git a/frontend/src/components/nodes/ConfigNode.tsx b/frontend/src/components/nodes/ConfigNode.tsx index 4adc924..275571c 100644 --- a/frontend/src/components/nodes/ConfigNode.tsx +++ b/frontend/src/components/nodes/ConfigNode.tsx @@ -43,7 +43,7 @@ import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators' import { NodeHeaderTitle } from '../base/NodeHeaderTitle' import { NodeMenubar } from '../base/NodeMenubar' -export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string } +export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string; /** @deprecated use content */ plantuml?: string } type Props = AbstractNodeProps @@ -324,7 +324,7 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) { /> -
+
} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
-
+
} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input"> 0 ? incomingIds[0] : null const srcNode = nodes.find((n: any) => n.id === srcId) - const configTypeId = srcNode?.type === 'config' ? getConfigTypeId(srcNode.data) : 'plantuml' - const sourceContent = srcNode?.type === 'config' ? getConfigContent(srcNode.data) : '' + const configTypeId = srcNode?.type === 'config' ? getConfigTypeId((srcNode.data ?? undefined) as Record | undefined) : 'plantuml' + const sourceContent = srcNode?.type === 'config' ? getConfigContent((srcNode.data ?? undefined) as Record | undefined) : '' const srcData = srcNode?.data ?? {} /** Set of node IDs that can affect this render node (configs in the chain + variables/functions feeding them) */ @@ -94,7 +94,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { if (!node) return visited.add(nodeId) out.add(nodeId) - const content = getConfigContent(node.data) + const content = getConfigContent((node.data ?? undefined) as Record | undefined) for (const ref of getTemplateRefs(content)) { const refId = resolveRef(ref) if (refId && nodes.some((n: any) => n.id === refId && n.type === 'config') && isReachable(refId, id)) @@ -223,7 +223,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { throw new Error(`Referenced config not connected to renderer: ${templateName}`) visited.add(refId) configIdsUsed.add(refId) - const content = getConfigContent(node.data) + const content = getConfigContent((node.data ?? undefined) as Record | undefined) for (const ref of getTemplateRefs(content)) addConfigAndRefs(ref, visited) } @@ -238,7 +238,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { if (refId !== srcId && !isReachable(refId, id)) throw new Error(`Referenced config not connected to renderer: ${name}`) return { - src: getConfigContent(node.data), + src: getConfigContent((node.data ?? undefined) as Record | undefined), path: name, } }, @@ -338,7 +338,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { for (const fid of functionIdsToRegister) { const src = nodes.find((n: any) => n.id === fid) if (!src || src.type !== 'function') continue - const body = src.data?.body ?? 'return args[0];' + const body = (src.data as { body?: string } | undefined)?.body ?? 'return args[0];' const parsed = parseFunctionSignature(body) const connectedVarIds = new Set(functionConnectedVariableIds[fid] ?? []) const connectedFuncIds = functionConnectedFunctionIds[fid] ?? [] @@ -432,7 +432,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { env.render(srcId!, nunjucksContext, async (nunjucksErr: Error | null, afterNunjucks: string) => { if (cancelled || thisRunId !== runIdRef.current) return if (nunjucksErr) { - setSvgContent(null) + setRenderedContent(null) setError({ kind: 'render', message: `Nunjucks: ${nunjucksErr.message}` }) setLoading(false) return @@ -677,10 +677,10 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { ) : error ? ( - srcData?.renderError ? ( - srcData.renderError(error) - ) : srcData?.errorHtml ? ( -
+ (srcData as { renderError?: (err: { kind: string; message: string }) => React.ReactNode; errorHtml?: string })?.renderError ? ( + (srcData as { renderError: (err: { kind: string; message: string }) => React.ReactNode }).renderError(error) + ) : (srcData as { errorHtml?: string })?.errorHtml ? ( +
) : (

{error.message}

@@ -706,7 +706,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { minScale={0.2} maxScale={4} centerOnInit - onInit={(ref) => ref?.centerView(1, 0, 0)} + onInit={(ref) => ref?.centerView(1, 200, 'easeOut')} panning={{ disabled: true }} wheel={{ disabled: true }} doubleClick={{ disabled: true }} diff --git a/frontend/src/lib/abstractNode.ts b/frontend/src/lib/abstractNode.ts index de49f55..c2a85a2 100644 --- a/frontend/src/lib/abstractNode.ts +++ b/frontend/src/lib/abstractNode.ts @@ -13,6 +13,7 @@ import React, { useCallback, useContext, useMemo } from 'react' import FlowContext from './flowContext' import { nodePropsAreEqual } from './flowUtils' +import type { AppNode } from './nodeTypes' // --------------------------------------------------------------------------- // Types @@ -74,10 +75,10 @@ export function useAbstractNode>( const updateData = useCallback( (partial: Partial) => { if (!setNodes) return - setNodes((nds: FlowNode[]) => + setNodes((nds: AppNode[]) => nds.map((n) => n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n - ) + ) as AppNode[] ) }, [id, setNodes] @@ -105,7 +106,7 @@ export function useAbstractNode>( data, nodes, edges, - setNodes: setNodes ?? (() => {}), + setNodes: ((setNodes ?? (() => {})) as AbstractNodeContext['setNodes']), setEdges: setEdges ?? (() => {}), updateData, incomingEdges, diff --git a/frontend/src/lib/flowUtils.ts b/frontend/src/lib/flowUtils.ts index 60bc48d..c6a5fb3 100644 --- a/frontend/src/lib/flowUtils.ts +++ b/frontend/src/lib/flowUtils.ts @@ -8,7 +8,6 @@ import { getIdPrefix, getDefaultDataForType as getDefaultDataFromRegistry, getResetDataForType as getResetDataFromRegistry, - getDefaultStyle, } from './nodeRegistry' export function nodePropsAreEqual

( @@ -24,14 +23,6 @@ export function nodePropsAreEqual

= { - config: 'cfg_', - render: 'rnd_', - variable: 'var_', - function: 'fn_', -} - /** Next node id for type: prefix + 3-digit increasing number (001, 002, …). Uses nodeRegistry for prefix when available. */ export function getNextNodeId(type: string, existingIds: string[]): string { const prefix = getIdPrefix(type) @@ -91,7 +82,3 @@ export function getResetDataForType(type: string, nodeId?: string): any { return getResetDataFromRegistry(type, nodeId) } -/** Default style for a type. Uses nodeRegistry when type is registered. */ -export function getDefaultStyleForType(type: string): { width: number; height: number } { - return getDefaultStyle(type) -} diff --git a/frontend/src/lib/plantumlLanguage.ts b/frontend/src/lib/plantumlLanguage.ts index 7c05cd6..247f911 100644 --- a/frontend/src/lib/plantumlLanguage.ts +++ b/frontend/src/lib/plantumlLanguage.ts @@ -1,7 +1,7 @@ import { StreamLanguage } from '@codemirror/language' /** Nunjucks block comment {# ... #} */ -function tokenNunjucksComment(stream: { match: (re: RegExp) => string | null; next: () => string; eol: () => boolean }) { +function tokenNunjucksComment(stream: { match: (re: RegExp) => unknown; next: () => string | void; eol: () => boolean }) { if (stream.match(/^\{#/)) { while (!stream.eol()) { if (stream.match(/#\}/)) return 'comment' @@ -13,7 +13,7 @@ function tokenNunjucksComment(stream: { match: (re: RegExp) => string | null; ne } /** Nunjucks variable {{ ... }} or tag {% ... %} - tokenize the whole block */ -function tokenNunjucksBlock(stream: { match: (re: RegExp) => string | null; next: () => string; eol: () => boolean }) { +function tokenNunjucksBlock(stream: { match: (re: RegExp) => unknown; next: () => string | void; eol: () => boolean }) { if (stream.match(/^\{\{/)) { while (!stream.eol()) { if (stream.match(/\}\}/)) return 'variableName.special' diff --git a/frontend/src/nunjucks.d.ts b/frontend/src/nunjucks.d.ts new file mode 100644 index 0000000..de84fbb --- /dev/null +++ b/frontend/src/nunjucks.d.ts @@ -0,0 +1,18 @@ +declare module 'nunjucks' { + export interface Loader { + getSource(name: string): { src: string; path: string } | null + } + export interface Environment { + render(name: string, context: Record, callback: (err: Error | null, res: string) => void): void + addFilter(name: string, fn: (...args: unknown[]) => void, async?: boolean): void + getFilter(name: string): (...args: unknown[]) => void + } + export class Environment { + constructor(loaders?: Loader[], opts?: { autoescape?: boolean }) + render(name: string, context: Record, callback: (err: Error | null, res: string) => void): void + addFilter(name: string, fn: (...args: unknown[]) => void, async?: boolean): void + getFilter(name: string): (...args: unknown[]) => void + } + const nunjucks: { Environment: typeof Environment } + export default nunjucks +}