improve rendering raw

This commit is contained in:
2026-03-10 22:44:58 +01:00
parent d5ddd762cd
commit 0c368f4b24
2 changed files with 70 additions and 22 deletions

View File

@@ -1,11 +1,16 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import nunjucks from 'nunjucks' import nunjucks from 'nunjucks'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import { markdown } from '@codemirror/lang-markdown'
import { import {
AbstractNodeProps, AbstractNodeProps,
createAbstractNodeComponent, createAbstractNodeComponent,
useAbstractNode, useAbstractNode,
} from '../../lib/abstractNode' } from '../../lib/abstractNode'
import { getConfigContent, getConfigType, getConfigTypeId } from '../../lib/configTypes' import { getConfigContent, getConfigType, getConfigTypeId } from '../../lib/configTypes'
import { useResizeHeight } from '../../hooks/useResizeHeight'
import { plantumlLanguage } from '../../lib/plantumlLanguage'
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty' import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty'
import { import {
BaseNode, BaseNode,
@@ -20,12 +25,13 @@ import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
import { NodeMenubar } from '../base/NodeMenubar' import { NodeMenubar } from '../base/NodeMenubar'
import { NodeStatusIndicator } from '../base/NodeStatusIndicator' import { NodeStatusIndicator } from '../base/NodeStatusIndicator'
import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar' import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar'
import { Sparkles, ZoomIn, ZoomOut, RotateCcw, RotateCw, Eye, FileCode } from 'lucide-react' import { Sparkles, ZoomIn, ZoomOut, RotateCcw, RotateCw, Copy } from 'lucide-react'
import { InputHandle } from '../base/NodeHandles' import { InputHandle } from '../base/NodeHandles'
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch' import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
import { Input } from '../ui/input' import { Input } from '../ui/input'
import { Button } from '../ui/button' import { Button } from '../ui/button'
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group' import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group'
import { useTheme } from '../../lib/themeContext'
export type RenderingNodeData = { export type RenderingNodeData = {
viewportWidth?: number viewportWidth?: number
@@ -552,17 +558,17 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
if (!ctx) return if (!ctx) return
ctx.drawImage(img, 0, 0) ctx.drawImage(img, 0, 0)
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
if (blob) navigator.clipboard?.write([new ClipboardItem({ 'image/png': blob })]).catch(() => {}) if (blob) navigator.clipboard?.write([new ClipboardItem({ 'image/png': blob })]).catch(() => { })
}, 'image/png') }, 'image/png')
} }
img.onerror = () => {} img.onerror = () => { }
img.src = dataUrl img.src = dataUrl
}, [renderedContent, isSvgOutput]) }, [renderedContent, isSvgOutput])
const copySvg = useCallback(() => { const copySvg = useCallback(() => {
if (!renderedContent || !isSvgOutput) return if (!renderedContent || !isSvgOutput) return
const blob = new Blob([renderedContent], { type: 'image/svg+xml' }) const blob = new Blob([renderedContent], { type: 'image/svg+xml' })
navigator.clipboard?.write([new ClipboardItem({ 'image/svg+xml': blob })]).catch(() => {}) navigator.clipboard?.write([new ClipboardItem({ 'image/svg+xml': blob })]).catch(() => { })
}, [renderedContent, isSvgOutput]) }, [renderedContent, isSvgOutput])
const status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial' const status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial'
@@ -579,32 +585,48 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
updateData({ viewportWidth: viewportDraft.width, viewportHeight: viewportDraft.height }) updateData({ viewportWidth: viewportDraft.width, viewportHeight: viewportDraft.height })
}, [updateData, viewportDraft.width, viewportDraft.height]) }, [updateData, viewportDraft.width, viewportDraft.height])
const { theme } = useTheme()
const [rawEditorHeight, rawEditorContainerRef] = useResizeHeight(180, [viewMode])
const rawExtensions = useMemo(() => {
const lang =
configTypeId === 'wireframe'
? javascript()
: configType.language === 'plantuml'
? plantumlLanguage.extension
: markdown()
return [lang]
}, [configTypeId, configType.language])
return ( return (
<NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}> <NodeStatusIndicator status={status} variant="border" width={dimensions?.width} height={dimensions?.height}>
<BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<InputHandle id="ain" nodeId={id} />}> <BaseNode className="min-w-96 min-h-[320px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<InputHandle id="ain" nodeId={id} />}>
<BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} /> <BaseNodeHeaderRow
icon={<Sparkles className="size-4" />}
<BaseNodeContent> title={<NodeHeaderTitle nodeId={id} displayTitle={id} />}
<div className="shrink-0 w-full flex items-center gap-2"> right={
{incomingIds.length > 0 && ( incomingIds.length > 0 ? (
<ToggleGroup <ToggleGroup
type="single" type="single"
value={viewMode} value={viewMode}
onValueChange={(v) => v && (v === 'preview' || v === 'raw') && setViewMode(v)} onValueChange={(v) => v && (v === 'preview' || v === 'raw') && setViewMode(v)}
className="shrink-0" aria-label="View mode"
variant="outline" variant="outline"
size="sm" size="sm"
className="gap-0 rounded-md p-0.5 [&>button]:rounded-none [&>button:first-child]:rounded-l-md [&>button:last-child]:rounded-r-md [&>button:not(:first-child)]:border-l-0"
> >
<ToggleGroupItem value="preview" aria-label="Preview" className="gap-1 px-2 text-xs"> <ToggleGroupItem value="preview" aria-label="Preview" className="gap-1.5 px-2.5 h-7">
<Eye className="size-3" />
Preview Preview
</ToggleGroupItem> </ToggleGroupItem>
<ToggleGroupItem value="raw" aria-label="Raw config" className="gap-1 px-2 text-xs"> <ToggleGroupItem value="raw" aria-label="Raw config" className="gap-1.5 px-2.5 h-7">
<FileCode className="size-3" />
Raw Raw
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
)} ) : undefined
}
/>
<BaseNodeContent>
<div className="shrink-0 w-full">
<NodeMenubar <NodeMenubar
nodeId={id} nodeId={id}
nodeType="render" nodeType="render"
@@ -729,10 +751,31 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
) : loading ? ( ) : loading ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering</div> <div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering</div>
) : viewMode === 'raw' ? ( ) : viewMode === 'raw' ? (
<div className="min-h-0 flex-1 w-full overflow-auto"> <div ref={rawEditorContainerRef} className="relative min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
<pre className="p-3 text-xs font-mono whitespace-pre-wrap break-words bg-muted/50 rounded m-2 min-h-0"> <Button
<code>{resolvedContent ?? '(no resolved config yet)'}</code> type="button"
</pre> variant="secondary"
size="icon"
className="absolute top-2 right-2 z-10 h-7 w-7 shrink-0 rounded-md shadow-sm"
onClick={() => {
const text = resolvedContent ?? ''
if (text) navigator.clipboard?.writeText(text).catch(() => {})
}}
disabled={!resolvedContent}
title="Copy raw output"
>
<Copy className="size-3.5" />
</Button>
<CodeMirror
value={resolvedContent ?? ''}
height={`${rawEditorHeight}px`}
theme={theme}
extensions={rawExtensions}
readOnly
editable={false}
basicSetup={{ lineNumbers: true, foldGutter: false }}
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0 [&_.cm-scroller]:min-h-0"
/>
</div> </div>
) : renderedContent ? ( ) : renderedContent ? (
outputType === 'image' ? ( outputType === 'image' ? (
@@ -748,7 +791,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
initialPositionY={0} initialPositionY={0}
minScale={0.2} minScale={0.2}
maxScale={4} maxScale={4}
centerOnInit={false} centerOnInit={true}
onInit={(ctx) => { onInit={(ctx) => {
if (!ctx?.instance?.wrapperComponent || !ctx?.instance?.contentComponent) return if (!ctx?.instance?.wrapperComponent || !ctx?.instance?.contentComponent) return
const fitToView = () => { const fitToView = () => {

View File

@@ -3,8 +3,13 @@ import { useEffect, useRef, useState } from 'react'
/** /**
* Returns the height of the observed element, updating when it resizes (e.g. node resize). * Returns the height of the observed element, updating when it resizes (e.g. node resize).
* Used to give CodeMirror and other components an explicit height that tracks their container. * Used to give CodeMirror and other components an explicit height that tracks their container.
* @param defaultHeight - Height used until the element is measured.
* @param deps - Optional dependency array; when the ref is attached to a conditionally mounted element, pass deps (e.g. [viewMode]) so the effect re-runs when the element appears.
*/ */
export function useResizeHeight(defaultHeight: number): [number, React.RefObject<HTMLDivElement | null>] { export function useResizeHeight(
defaultHeight: number,
deps?: React.DependencyList
): [number, React.RefObject<HTMLDivElement | null>] {
const ref = useRef<HTMLDivElement | null>(null) const ref = useRef<HTMLDivElement | null>(null)
const [height, setHeight] = useState(defaultHeight) const [height, setHeight] = useState(defaultHeight)
@@ -21,7 +26,7 @@ export function useResizeHeight(defaultHeight: number): [number, React.RefObject
ro.observe(el) ro.observe(el)
setHeight(el.getBoundingClientRect().height) setHeight(el.getBoundingClientRect().height)
return () => ro.disconnect() return () => ro.disconnect()
}, []) }, deps ?? [])
return [height, ref] return [height, ref]
} }