improve rendering
This commit is contained in:
15
package-lock.json
generated
15
package-lock.json
generated
@@ -27,6 +27,7 @@
|
||||
"nunjucks": "^3.2.4",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"reactflow": "^11.0.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
@@ -6976,6 +6977,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-zoom-pan-pinch": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz",
|
||||
"integrity": "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
"npm": ">=5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-dom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/reactflow": {
|
||||
"version": "11.11.4",
|
||||
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"nunjucks": "^3.2.4",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"reactflow": "^11.0.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -108,15 +108,20 @@ async function renderMarkdown(content: string): Promise<string> {
|
||||
return typeof html === 'string' ? html : String(html)
|
||||
}
|
||||
|
||||
/** Wireweave DSL to HTML+CSS; theme from document dark mode. See https://www.wireweave.org/ */
|
||||
async function renderWireframe(content: string): Promise<string> {
|
||||
const { parse, render } = await import('@wireweave/core')
|
||||
/** Wireweave DSL to SVG; theme from document dark mode. See https://www.wireweave.org/ */
|
||||
async function renderWireframe(content: string, options?: RenderOptions): Promise<string> {
|
||||
const { parse, renderToSvg } = await import('@wireweave/core')
|
||||
const doc = parse(content)
|
||||
const isDark =
|
||||
typeof document !== 'undefined' &&
|
||||
document.documentElement?.classList?.contains('dark')
|
||||
const { html, css } = render(doc, { theme: isDark ? 'dark' : 'light' })
|
||||
return `<style>${css}</style>${html}`
|
||||
const { svg } = renderToSvg(doc, {
|
||||
theme: isDark ? 'dark' : 'light',
|
||||
width: options?.width ?? 1200,
|
||||
height: options?.height,
|
||||
padding: 24,
|
||||
})
|
||||
return svg
|
||||
}
|
||||
|
||||
export const CONFIG_TYPES: ConfigType[] = [
|
||||
|
||||
@@ -45,7 +45,7 @@ export function registerBuiltinNodes(): void {
|
||||
id: 'render',
|
||||
component: RenderingNode,
|
||||
defaultStyle: { width: 384, height: 320 },
|
||||
defaultData: {},
|
||||
defaultData: { viewportWidth: 1200, viewportHeight: 800 },
|
||||
idPrefix: 'rnd_',
|
||||
hasInput: true,
|
||||
hasOutput: false,
|
||||
|
||||
@@ -56,6 +56,15 @@ body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Viewport control buttons: same look as React Flow controls; Lucide icons use stroke. */
|
||||
.rendering-viewport .react-flow__controls-button svg {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Rendering node: diagram/wireframe SVG must fit inside the content area. */
|
||||
.rendering-diagram {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user