diff --git a/src/App.tsx b/src/App.tsx index a37661c..6dbdfa0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,8 @@ import { addEdge, applyNodeChanges, applyEdgeChanges, + useNodesInitialized, + useReactFlow, type Node, type Edge, type Connection, @@ -107,6 +109,18 @@ const PROJECT_VERSION = 1 export type ProjectMessage = { type: 'success' | 'error'; text: string } +/** Calls fitView when nodes are initialized (e.g. after load/import). Must be rendered inside ReactFlowProvider. */ +function FlowFitViewOnLoad() { + const nodesInitialized = useNodesInitialized() + const { fitView } = useReactFlow() + React.useEffect(() => { + if (nodesInitialized) { + fitView?.({ duration: 200 }) + } + }, [nodesInitialized, fitView]) + return null +} + export default function App() { const { theme } = useTheme() const { @@ -488,6 +502,7 @@ export default function App() { )} + diff --git a/src/components/nodes/RenderingNode.tsx b/src/components/nodes/RenderingNode.tsx index 9b14e07..d2fb8c1 100644 --- a/src/components/nodes/RenderingNode.tsx +++ b/src/components/nodes/RenderingNode.tsx @@ -20,10 +20,11 @@ import { NodeHeaderTitle } from '../base/NodeHeaderTitle' import { NodeMenubar } from '../base/NodeMenubar' import { NodeStatusIndicator } from '../base/NodeStatusIndicator' import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar' -import { Sparkles, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react' +import { Sparkles, ZoomIn, ZoomOut, RotateCcw, RotateCw } from 'lucide-react' import { InputHandle } from '../base/NodeHandles' import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch' import { Input } from '../ui/input' +import { Button } from '../ui/button' export type RenderingNodeData = { viewportWidth?: number @@ -39,6 +40,7 @@ function RenderingNodeComponent({ id, data, width, height }: Props) { const [renderedContent, setRenderedContent] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) + const [retryCount, setRetryCount] = useState(0) const runIdRef = useRef(0) const loadingStartedAtRef = useRef(null) const minLoadingTimeoutRef = useRef | null>(null) @@ -151,6 +153,8 @@ function RenderingNodeComponent({ id, data, width, height }: Props) { [nodes, connectedNodeIds] ) + const RENDER_DEBOUNCE_MS = 250 + useEffect(() => { if (!sourceContent && incomingIds.length > 0) { setRenderedContent(null) @@ -473,16 +477,17 @@ function RenderingNodeComponent({ id, data, width, height }: Props) { } } - run() + const debounceTimer = setTimeout(run, RENDER_DEBOUNCE_MS) return () => { cancelled = true + clearTimeout(debounceTimer) if (minLoadingTimeoutRef.current != null) { clearTimeout(minLoadingTimeoutRef.current) minLoadingTimeoutRef.current = null } } - // Only re-run when inputs that affect the resolved output change (signatures + source). - }, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, viewportWidth, viewportHeight]) + // Only re-run when inputs that affect the resolved output change (signatures + source). Debounced to avoid excessive re-renders while typing. retryCount triggers re-run on Retry. + }, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, viewportWidth, viewportHeight, retryCount]) const dimensions = width != null && height != null && width > 0 && height > 0 @@ -646,7 +651,19 @@ function RenderingNodeComponent({ id, data, width, height }: Props) { ) : srcData?.errorHtml ? (
) : ( -
{error.message}
+
+

{error.message}

+ +
) ) : loading ? (
Rendering…
diff --git a/src/lib/configTypes.ts b/src/lib/configTypes.ts index 2096782..edbc011 100644 --- a/src/lib/configTypes.ts +++ b/src/lib/configTypes.ts @@ -33,6 +33,7 @@ export type ConfigType = { } const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg' +const KROKI_TIMEOUT_MS = 15000 const PLANTUML_INSERT_BLOCKS: InsertBlockOrGroup[] = [ { label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' }, @@ -131,16 +132,36 @@ export const CONFIG_TYPES: ConfigType[] = [ language: 'plantuml', insertBlocks: PLANTUML_INSERT_BLOCKS, render: async (content: string) => { - const res = await fetch(KROKI_PLANTUML_SVG, { - method: 'POST', - headers: { 'Content-Type': 'text/plain' }, - body: content, - }) - if (!res.ok) { - const err = await res.text() - throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`) + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), KROKI_TIMEOUT_MS) + try { + const res = await fetch(KROKI_PLANTUML_SVG, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: content, + signal: controller.signal, + }) + clearTimeout(timeoutId) + if (!res.ok) { + const err = await res.text() + if (res.status >= 500) { + throw new Error('Diagram service unavailable. Try again later.') + } + throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`) + } + return res.text() + } catch (err: unknown) { + clearTimeout(timeoutId) + if (err instanceof Error) { + if (err.name === 'AbortError') { + throw new Error('Diagram request timed out. The service may be slow or unavailable.') + } + if (err.message.includes('fetch') || err.message.includes('network') || err.message.includes('Failed to fetch')) { + throw new Error('Diagram service unavailable. Check your connection or try again later.') + } + } + throw err } - return res.text() }, }, {