feat: fix viewport
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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<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 +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<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 +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) {
|
||||
<BaseNodeHeaderRow icon={<Sparkles className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
||||
|
||||
<BaseNodeContent>
|
||||
<div className="shrink-0 w-full">
|
||||
<div className="shrink-0 w-full flex items-center gap-2">
|
||||
{incomingIds.length > 0 && (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={viewMode}
|
||||
onValueChange={(v) => v && (v === 'preview' || v === 'raw') && setViewMode(v)}
|
||||
className="shrink-0"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem value="preview" aria-label="Preview" className="gap-1 px-2 text-xs">
|
||||
<Eye className="size-3" />
|
||||
Preview
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="raw" aria-label="Raw config" className="gap-1 px-2 text-xs">
|
||||
<FileCode className="size-3" />
|
||||
Raw
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)}
|
||||
<NodeMenubar
|
||||
nodeId={id}
|
||||
nodeType="render"
|
||||
nodeMenuExtraContent={
|
||||
<>
|
||||
{isSvgOutput && (
|
||||
{outputType === 'image' && (
|
||||
<MenubarSub>
|
||||
<MenubarSubTrigger className="text-xs">Viewport</MenubarSubTrigger>
|
||||
<MenubarSubContent className="min-w-[12rem] p-2">
|
||||
@@ -698,18 +728,51 @@ 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 className="min-h-0 flex-1 w-full overflow-auto">
|
||||
<pre className="p-3 text-xs font-mono whitespace-pre-wrap break-words bg-muted/50 rounded m-2 min-h-0">
|
||||
<code>{resolvedContent ?? '(no resolved config yet)'}</code>
|
||||
</pre>
|
||||
</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={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) {
|
||||
<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 +829,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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user