diff --git a/frontend/src/app/canvas/CanvasPage.tsx b/frontend/src/app/canvas/CanvasPage.tsx index 3be1507..0f67438 100644 --- a/frontend/src/app/canvas/CanvasPage.tsx +++ b/frontend/src/app/canvas/CanvasPage.tsx @@ -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(null) const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null) const wrapperRef = useRef(null) + const canvasWrapperRef = useRef(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) @@ -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) {
({ + ...n, + className: [n.className, 'nowheel'].filter(Boolean).join(' '), + }))} edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} diff --git a/frontend/src/components/nodes/RenderingNode.tsx b/frontend/src/components/nodes/RenderingNode.tsx index b59483a..50f9a99 100644 --- a/frontend/src/components/nodes/RenderingNode.tsx +++ b/frontend/src/components/nodes/RenderingNode.tsx @@ -20,11 +20,12 @@ 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, Eye, FileCode } 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' export type RenderingNodeData = { viewportWidth?: number @@ -36,11 +37,16 @@ const DEFAULT_VIEWPORT_HEIGHT = 800 type Props = AbstractNodeProps +type ViewMode = 'preview' | 'raw' + function RenderingNodeComponent({ id, data, width, height, selected }: Props) { const [renderedContent, setRenderedContent] = useState(null) + const [resolvedContent, setResolvedContent] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const [retryCount, setRetryCount] = useState(0) + const [viewMode, setViewMode] = useState('preview') + const [viewportFocused, setViewportFocused] = useState(false) const runIdRef = useRef(0) const loadingStartedAtRef = useRef(null) const minLoadingTimeoutRef = useRef | null>(null) @@ -52,6 +58,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 | undefined) : 'plantuml' + const configType = getConfigType(configTypeId) + const outputType = configType.outputType const sourceContent = srcNode?.type === 'config' ? getConfigContent((srcNode.data ?? undefined) as Record | undefined) : '' const srcData = srcNode?.data ?? {} @@ -158,12 +166,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 +443,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) @@ -574,13 +585,32 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { } title={} /> -
+
+ {incomingIds.length > 0 && ( + v && (v === 'preview' || v === 'raw') && setViewMode(v)} + className="shrink-0" + variant="outline" + size="sm" + > + + + Preview + + + + Raw + + + )} - {isSvgOutput && ( + {outputType === 'image' && ( Viewport @@ -698,18 +728,51 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { ) ) : loading ? (
Rendering…
+ ) : viewMode === 'raw' ? ( +
+
+                                    {resolvedContent ?? '(no resolved config yet)'}
+                                
+
) : renderedContent ? ( - isSvgOutput ? ( -
+ outputType === 'image' ? ( +
setViewportFocused(true)} + onBlur={() => setViewportFocused(false)} + > ref?.centerView(1, 200, 'easeOut')} - panning={{ disabled: true }} - wheel={{ disabled: true }} - doubleClick={{ disabled: true }} + centerOnInit={false} + 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 +802,13 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
-
+
@@ -766,11 +829,13 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { - {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' + : '—'} diff --git a/frontend/src/lib/configTypes.ts b/frontend/src/lib/configTypes.ts index edbc011..e06dbcd 100644 --- a/frontend/src/lib/configTypes.ts +++ b/frontend/src/lib/configTypes.ts @@ -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,