improve rendering

This commit is contained in:
2026-03-09 14:56:54 +01:00
parent 9565a425b6
commit 84e3647fb5
6 changed files with 180 additions and 38 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import nunjucks from 'nunjucks'
import {
AbstractNodeProps,
@@ -20,21 +20,31 @@ 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 } from 'lucide-react'
import { Sparkles, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'
import { InputHandle } from '../base/NodeHandles'
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
import { Input } from '../ui/input'
export type RenderingNodeData = Record<string, unknown>
export type RenderingNodeData = {
viewportWidth?: number
viewportHeight?: number
}
const DEFAULT_VIEWPORT_WIDTH = 1200
const DEFAULT_VIEWPORT_HEIGHT = 800
type Props = AbstractNodeProps<RenderingNodeData>
function RenderingNodeComponent({ id, width, height }: Props) {
function RenderingNodeComponent({ id, data, width, height }: Props) {
const [renderedContent, setRenderedContent] = useState<string | null>(null)
const [error, setError] = useState<null | { kind: string; message: string }>(null)
const [loading, setLoading] = useState(false)
const runIdRef = useRef(0)
const loadingStartedAtRef = useRef<number | null>(null)
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const { nodes, edges, setNodes, setEdges, sourceIds } = useAbstractNode<RenderingNodeData>(id, {})
const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode<RenderingNodeData>(id, data ?? {})
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
const incomingIds = sourceIds
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
@@ -428,7 +438,8 @@ function RenderingNodeComponent({ id, width, height }: Props) {
const typeRenderer = getConfigType(configTypeId)
try {
const htmlOrSvg = await typeRenderer.render(resolvedContent)
const renderOptions = configTypeId === 'wireframe' ? { width: viewportWidth, height: viewportHeight } : undefined
const htmlOrSvg = await typeRenderer.render(resolvedContent, renderOptions)
if (cancelled || thisRunId !== runIdRef.current) return
setRenderedContent(htmlOrSvg)
setError(null)
@@ -471,7 +482,7 @@ function RenderingNodeComponent({ id, width, height }: Props) {
}
}
// Only re-run when inputs that affect the resolved output change (signatures + source).
}, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature])
}, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, viewportWidth, viewportHeight])
const dimensions =
width != null && height != null && width > 0 && height > 0
@@ -515,7 +526,17 @@ function RenderingNodeComponent({ id, width, height }: Props) {
const status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial'
const isWireframeOutput = configTypeId === 'wireframe' && renderedContent && !isSvgOutput
const [viewportDraft, setViewportDraft] = useState({ width: viewportWidth, height: viewportHeight })
useEffect(() => {
setViewportDraft({ width: viewportWidth, height: viewportHeight })
}, [viewportWidth, viewportHeight])
const onViewportDraftChange = useCallback((field: 'width' | 'height', value: number) => {
setViewportDraft((prev) => ({ ...prev, [field]: Math.min(4000, Math.max(200, value)) }))
}, [])
const onViewportApply = useCallback(() => {
updateData({ viewportWidth: viewportDraft.width, viewportHeight: viewportDraft.height })
}, [updateData, viewportDraft.width, viewportDraft.height])
return (
<NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}>
@@ -528,20 +549,66 @@ function RenderingNodeComponent({ id, width, height }: Props) {
nodeId={id}
nodeType="render"
nodeMenuExtraContent={
<MenubarSub>
<MenubarSeparator></MenubarSeparator>
<MenubarSubTrigger className="text-xs" disabled={!isSvgOutput}>
Export
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[10rem]">
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!isSvgOutput}>
PNG
</MenubarItem>
<MenubarItem className="text-xs" onClick={downloadSvg} disabled={!isSvgOutput}>
SVG
</MenubarItem>
</MenubarSubContent>
</MenubarSub>
<>
{isSvgOutput && (
<MenubarSub>
<MenubarSubTrigger className="text-xs">Viewport</MenubarSubTrigger>
<MenubarSubContent className="min-w-[12rem] p-2">
<div className="grid gap-2">
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground shrink-0">Width</label>
<Input
type="number"
min={200}
max={4000}
value={viewportDraft.width}
onChange={(e) => {
const v = parseInt(e.target.value, 10)
if (!Number.isNaN(v)) onViewportDraftChange('width', v)
}}
className="h-7 text-xs"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground shrink-0">Height</label>
<Input
type="number"
min={200}
max={4000}
value={viewportDraft.height}
onChange={(e) => {
const v = parseInt(e.target.value, 10)
if (!Number.isNaN(v)) onViewportDraftChange('height', v)
}}
className="h-7 text-xs"
/>
</div>
<button
type="button"
onClick={onViewportApply}
className="mt-1 w-full rounded bg-primary px-2 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Apply
</button>
</div>
</MenubarSubContent>
</MenubarSub>
)}
<MenubarSub>
<MenubarSeparator />
<MenubarSubTrigger className="text-xs" disabled={!isSvgOutput}>
Export
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[10rem]">
<MenubarItem className="text-xs" onClick={downloadPng} disabled={!isSvgOutput}>
PNG
</MenubarItem>
<MenubarItem className="text-xs" onClick={downloadSvg} disabled={!isSvgOutput}>
SVG
</MenubarItem>
</MenubarSubContent>
</MenubarSub>
</>
}
/>
</div>
@@ -584,19 +651,64 @@ function RenderingNodeComponent({ id, width, height }: Props) {
) : loading ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering</div>
) : renderedContent ? (
isWireframeOutput ? (
<div className="rendering-wireframe min-h-0 flex-1 w-full min-w-0 flex flex-col overflow-hidden bg-background">
<div className="rendering-wireframe__viewport">
<div className="rendering-wireframe__content" dangerouslySetInnerHTML={{ __html: renderedContent }} />
</div>
isSvgOutput ? (
<div className="rendering-viewport nodrag nopan relative min-h-0 flex-1 w-full min-w-0 overflow-hidden bg-white dark:bg-secondary">
<TransformWrapper
initialScale={1}
minScale={0.2}
maxScale={4}
centerOnInit
onInit={(ref) => ref?.centerView(1, 0, 0)}
panning={{ disabled: true }}
wheel={{ disabled: true }}
doubleClick={{ disabled: true }}
>
{({ zoomIn, zoomOut, resetTransform }) => (
<>
<div className="react-flow__controls absolute bottom-2 left-2 z-10 nodrag nopan">
<button
type="button"
onClick={() => zoomIn()}
className="react-flow__controls-button"
title="Zoom in"
>
<ZoomIn className="size-3 max-w-[12px] max-h-[12px]" />
</button>
<button
type="button"
onClick={() => zoomOut()}
className="react-flow__controls-button"
title="Zoom out"
>
<ZoomOut className="size-3 max-w-[12px] max-h-[12px]" />
</button>
<button
type="button"
onClick={() => resetTransform()}
className="react-flow__controls-button"
title="Reset view (fit all)"
>
<RotateCcw className="size-3 max-w-[12px] max-h-[12px]" />
</button>
</div>
<div className="absolute inset-0 nodrag nopan">
<TransformComponent
wrapperClass="!w-full !h-full"
contentClass="!w-full !h-full flex items-center justify-center nodrag nopan"
>
<div
className="rendering-diagram flex items-center justify-center min-h-full min-w-full p-4 nodrag nopan"
dangerouslySetInnerHTML={{ __html: renderedContent }}
/>
</TransformComponent>
</div>
</>
)}
</TransformWrapper>
</div>
) : (
<div
className={
isSvgOutput
? 'rendering-diagram min-h-0 flex-1 w-full overflow-auto bg-white dark:bg-secondary'
: 'rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded'
}
className="rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded"
dangerouslySetInnerHTML={{ __html: renderedContent }}
/>
)