Compare commits

...

2 Commits

Author SHA1 Message Date
0c368f4b24 improve rendering raw 2026-03-10 22:44:58 +01:00
d5ddd762cd feat: fix viewport 2026-03-10 22:24:24 +01:00
4 changed files with 153 additions and 36 deletions

View File

@@ -3,7 +3,7 @@
* Rendered inside the platform when a project is selected.
*/
import React, { useCallback, useMemo, useRef } from 'react'
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import {
ReactFlow,
ReactFlowProvider,
@@ -181,6 +181,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null)
const wrapperRef = useRef<HTMLDivElement | null>(null)
const canvasWrapperRef = useRef<HTMLDivElement | null>(null)
const lastClickRef = useRef<{ clientX: number; clientY: number } | null>(null)
const flowActionsRef = useRef<{ pasteAtViewportCenter: () => void; fitView: () => void } | null>(null)
const [contextTarget, setContextTarget] = React.useState<null | { type: 'canvas'; clientX: number; clientY: number }>(null)
@@ -367,14 +368,6 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
}
}, [])
const onWheelCapture = useCallback((ev: React.WheelEvent) => {
const target = ev.target as HTMLElement
if (target.closest('.react-flow__node')) {
ev.preventDefault()
ev.stopPropagation()
}
}, [])
const onCanvasContextMenu = useCallback((ev: React.MouseEvent) => {
ev.preventDefault()
lastClickRef.current = { clientX: ev.clientX, clientY: ev.clientY }
@@ -518,8 +511,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
<ContextMenu>
<ContextMenuTrigger asChild>
<div
ref={canvasWrapperRef}
className="flex-1 min-h-0 w-full relative"
onWheelCapture={onWheelCapture}
role="application"
aria-label="Graph canvas"
tabIndex={0}
@@ -556,7 +549,10 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
<FlowFitViewOnLoad />
<FlowKeyboardShortcuts />
<ReactFlow
nodes={nodes}
nodes={nodes.map((n) => ({
...n,
className: [n.className, 'nowheel'].filter(Boolean).join(' '),
}))}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}

View File

@@ -1,11 +1,16 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import nunjucks from 'nunjucks'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import { markdown } from '@codemirror/lang-markdown'
import {
AbstractNodeProps,
createAbstractNodeComponent,
useAbstractNode,
} from '../../lib/abstractNode'
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 {
BaseNode,
@@ -20,11 +25,13 @@ 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, RotateCw } from 'lucide-react'
import { Sparkles, ZoomIn, ZoomOut, RotateCcw, RotateCw, Copy } 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'
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group'
import { useTheme } from '../../lib/themeContext'
export type RenderingNodeData = {
viewportWidth?: number
@@ -36,11 +43,16 @@ const DEFAULT_VIEWPORT_HEIGHT = 800
type Props = AbstractNodeProps<RenderingNodeData>
type ViewMode = 'preview' | 'raw'
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const [renderedContent, setRenderedContent] = useState<string | null>(null)
const [resolvedContent, setResolvedContent] = useState<string | null>(null)
const [error, setError] = useState<null | { kind: string; message: string }>(null)
const [loading, setLoading] = useState(false)
const [retryCount, setRetryCount] = useState(0)
const [viewMode, setViewMode] = useState<ViewMode>('preview')
const [viewportFocused, setViewportFocused] = useState(false)
const runIdRef = useRef(0)
const loadingStartedAtRef = useRef<number | null>(null)
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -52,6 +64,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
const srcNode = nodes.find((n: any) => n.id === srcId)
const configTypeId = srcNode?.type === 'config' ? getConfigTypeId((srcNode.data ?? undefined) as Record<string, unknown> | undefined) : 'plantuml'
const configType = getConfigType(configTypeId)
const outputType = configType.outputType
const sourceContent = srcNode?.type === 'config' ? getConfigContent((srcNode.data ?? undefined) as Record<string, unknown> | undefined) : ''
const srcData = srcNode?.data ?? {}
@@ -158,12 +172,14 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
useEffect(() => {
if (!sourceContent && incomingIds.length > 0) {
setRenderedContent(null)
setResolvedContent(null)
setError({ kind: 'no-content', message: 'No content on connected configuration node' })
setLoading(false)
return
}
if (incomingIds.length === 0) {
setRenderedContent(null)
setResolvedContent(null)
setError(null)
setLoading(false)
return
@@ -433,17 +449,18 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
if (cancelled || thisRunId !== runIdRef.current) return
if (nunjucksErr) {
setRenderedContent(null)
setResolvedContent(null)
setError({ kind: 'render', message: `Nunjucks: ${nunjucksErr.message}` })
setLoading(false)
return
}
const resolvedContent = afterNunjucks
setResolvedContent(afterNunjucks)
const typeRenderer = getConfigType(configTypeId)
try {
const renderOptions = configTypeId === 'wireframe' ? { width: viewportWidth, height: viewportHeight } : undefined
const htmlOrSvg = await typeRenderer.render(resolvedContent, renderOptions)
const htmlOrSvg = await typeRenderer.render(afterNunjucks, renderOptions)
if (cancelled || thisRunId !== runIdRef.current) return
setRenderedContent(htmlOrSvg)
setError(null)
@@ -541,17 +558,17 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
if (!ctx) return
ctx.drawImage(img, 0, 0)
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')
}
img.onerror = () => {}
img.onerror = () => { }
img.src = dataUrl
}, [renderedContent, isSvgOutput])
const copySvg = useCallback(() => {
if (!renderedContent || !isSvgOutput) return
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])
const status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial'
@@ -568,10 +585,45 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
updateData({ viewportWidth: viewportDraft.width, viewportHeight: 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 (
<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} />}>
<BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
<BaseNodeHeaderRow
icon={<Sparkles className="size-4" />}
title={<NodeHeaderTitle nodeId={id} displayTitle={id} />}
right={
incomingIds.length > 0 ? (
<ToggleGroup
type="single"
value={viewMode}
onValueChange={(v) => v && (v === 'preview' || v === 'raw') && setViewMode(v)}
aria-label="View mode"
variant="outline"
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.5 px-2.5 h-7">
Preview
</ToggleGroupItem>
<ToggleGroupItem value="raw" aria-label="Raw config" className="gap-1.5 px-2.5 h-7">
Raw
</ToggleGroupItem>
</ToggleGroup>
) : undefined
}
/>
<BaseNodeContent>
<div className="shrink-0 w-full">
@@ -580,7 +632,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
nodeType="render"
nodeMenuExtraContent={
<>
{isSvgOutput && (
{outputType === 'image' && (
<MenubarSub>
<MenubarSubTrigger className="text-xs">Viewport</MenubarSubTrigger>
<MenubarSubContent className="min-w-[12rem] p-2">
@@ -698,18 +750,72 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
)
) : loading ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering</div>
) : viewMode === 'raw' ? (
<div ref={rawEditorContainerRef} className="relative min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
<Button
type="button"
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>
) : renderedContent ? (
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">
outputType === 'image' ? (
<div
className="rendering-viewport nodrag nopan relative min-h-0 flex-1 w-full min-w-0 overflow-hidden bg-white dark:bg-secondary rounded outline-none"
tabIndex={0}
onFocus={() => setViewportFocused(true)}
onBlur={() => setViewportFocused(false)}
>
<TransformWrapper
initialScale={1}
initialPositionX={0}
initialPositionY={0}
minScale={0.2}
maxScale={4}
centerOnInit
onInit={(ref) => ref?.centerView(1, 200, 'easeOut')}
panning={{ disabled: true }}
wheel={{ disabled: true }}
doubleClick={{ disabled: true }}
centerOnInit={true}
onInit={(ctx) => {
if (!ctx?.instance?.wrapperComponent || !ctx?.instance?.contentComponent) return
const fitToView = () => {
const wrapper = ctx.instance.wrapperComponent
const content = ctx.instance.contentComponent
if (!wrapper || !content) return
const wW = wrapper.clientWidth
const wH = wrapper.clientHeight
const cW = content.scrollWidth || content.clientWidth
const cH = content.scrollHeight || content.clientHeight
if (cW > 0 && cH > 0) {
const scale = Math.min(wW / cW, wH / cH, 1)
const posX = (wW - cW * scale) / 2
const posY = (wH - cH * scale) / 2
ctx.setTransform(posX, posY, scale, 0)
}
}
requestAnimationFrame(() => {
requestAnimationFrame(fitToView)
})
}}
panning={{ disabled: !selected && !viewportFocused }}
wheel={{ disabled: !selected && !viewportFocused }}
doubleClick={{ disabled: !selected && !viewportFocused }}
>
{({ zoomIn, zoomOut, resetTransform }) => (
<>
@@ -739,13 +845,13 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
<RotateCcw className="size-3 max-w-[12px] max-h-[12px]" />
</button>
</div>
<div className="absolute inset-0 nodrag nopan">
<div className="absolute inset-0 nodrag nopan overflow-hidden">
<TransformComponent
wrapperClass="!w-full !h-full"
contentClass="!w-full !h-full flex items-center justify-center nodrag nopan"
contentClass="inline-flex nodrag nopan"
>
<div
className="rendering-diagram flex items-center justify-center min-h-full min-w-full p-4 nodrag nopan"
className="rendering-diagram inline-flex p-4 nodrag nopan"
dangerouslySetInnerHTML={{ __html: renderedContent }}
/>
</TransformComponent>
@@ -766,11 +872,13 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
<BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="render">
{renderedContent
? `${getConfigType(configTypeId).label} · ${renderedContent.length} chars`
: error
? 'Error'
: '—'}
{viewMode === 'raw'
? (resolvedContent != null ? `Raw · ${resolvedContent.length} chars` : '—')
: renderedContent
? `${configType.label} · ${renderedContent.length} chars`
: error
? 'Error'
: '—'}
</NodeFooterEdgeIndicators>
</BaseNodeFooter>
</BaseNode>

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).
* 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 [height, setHeight] = useState(defaultHeight)
@@ -21,7 +26,7 @@ export function useResizeHeight(defaultHeight: number): [number, React.RefObject
ro.observe(el)
setHeight(el.getBoundingClientRect().height)
return () => ro.disconnect()
}, [])
}, deps ?? [])
return [height, ref]
}

View File

@@ -21,9 +21,14 @@ function isGroup(b: InsertBlockOrGroup): b is InsertBlockGroup {
/** Optional options passed to render (e.g. node size for wireframe SVG). */
export type RenderOptions = { width?: number; height?: number }
/** How the renderer should display this type: HTML in a div, or image (SVG/PNG) in a viewport. */
export type ConfigOutputType = 'html' | 'image'
export type ConfigType = {
id: ConfigTypeId
label: string
/** How the RenderingNode should display output: 'html' (div) or 'image' (viewport). */
outputType: ConfigOutputType
/** CodeMirror language key; used to pick the extension in ConfigNode. */
language: ConfigTypeId
/** Options under Insert → [type-specific]. Can be flat blocks or groups. */
@@ -129,6 +134,7 @@ export const CONFIG_TYPES: ConfigType[] = [
{
id: 'plantuml',
label: 'Diagram',
outputType: 'image',
language: 'plantuml',
insertBlocks: PLANTUML_INSERT_BLOCKS,
render: async (content: string) => {
@@ -167,6 +173,7 @@ export const CONFIG_TYPES: ConfigType[] = [
{
id: 'markdown',
label: 'Markdown',
outputType: 'html',
language: 'markdown',
insertBlocks: MARKDOWN_INSERT_BLOCKS,
render: renderMarkdown,
@@ -174,6 +181,7 @@ export const CONFIG_TYPES: ConfigType[] = [
{
id: 'wireframe',
label: 'Wireframe',
outputType: 'image',
language: 'markdown',
insertBlocks: WIREFRAME_INSERT_BLOCKS,
render: renderWireframe,