improve rendering raw
This commit is contained in:
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user