diff --git a/package-lock.json b/package-lock.json index 73d9935..b2521df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "nunjucks": "^3.2.4", "react": "18.2.0", "react-dom": "18.2.0", + "react-zoom-pan-pinch": "^3.7.0", "reactflow": "^11.0.0", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7" @@ -6976,6 +6977,20 @@ } } }, + "node_modules/react-zoom-pan-pinch": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz", + "integrity": "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==", + "license": "MIT", + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, "node_modules/reactflow": { "version": "11.11.4", "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", diff --git a/package.json b/package.json index 4b3ac4a..e793b7f 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "nunjucks": "^3.2.4", "react": "18.2.0", "react-dom": "18.2.0", + "react-zoom-pan-pinch": "^3.7.0", "reactflow": "^11.0.0", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7" diff --git a/src/components/nodes/RenderingNode.tsx b/src/components/nodes/RenderingNode.tsx index e2925e2..9b14e07 100644 --- a/src/components/nodes/RenderingNode.tsx +++ b/src/components/nodes/RenderingNode.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import nunjucks from 'nunjucks' import { AbstractNodeProps, @@ -20,21 +20,31 @@ 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 } from 'lucide-react' +import { Sparkles, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react' import { InputHandle } from '../base/NodeHandles' +import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch' +import { Input } from '../ui/input' -export type RenderingNodeData = Record +export type RenderingNodeData = { + viewportWidth?: number + viewportHeight?: number +} + +const DEFAULT_VIEWPORT_WIDTH = 1200 +const DEFAULT_VIEWPORT_HEIGHT = 800 type Props = AbstractNodeProps -function RenderingNodeComponent({ id, width, height }: Props) { +function RenderingNodeComponent({ id, data, width, height }: Props) { const [renderedContent, setRenderedContent] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const runIdRef = useRef(0) const loadingStartedAtRef = useRef(null) const minLoadingTimeoutRef = useRef | null>(null) - const { nodes, edges, setNodes, setEdges, sourceIds } = useAbstractNode(id, {}) + const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode(id, data ?? {}) + const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH + const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT const incomingIds = sourceIds const srcId = incomingIds.length > 0 ? incomingIds[0] : null @@ -428,7 +438,8 @@ function RenderingNodeComponent({ id, width, height }: Props) { const typeRenderer = getConfigType(configTypeId) try { - const htmlOrSvg = await typeRenderer.render(resolvedContent) + const renderOptions = configTypeId === 'wireframe' ? { width: viewportWidth, height: viewportHeight } : undefined + const htmlOrSvg = await typeRenderer.render(resolvedContent, renderOptions) if (cancelled || thisRunId !== runIdRef.current) return setRenderedContent(htmlOrSvg) setError(null) @@ -471,7 +482,7 @@ function RenderingNodeComponent({ id, width, height }: Props) { } } // Only re-run when inputs that affect the resolved output change (signatures + source). - }, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature]) + }, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, viewportWidth, viewportHeight]) const dimensions = width != null && height != null && width > 0 && height > 0 @@ -515,7 +526,17 @@ function RenderingNodeComponent({ id, width, height }: Props) { const status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial' - const isWireframeOutput = configTypeId === 'wireframe' && renderedContent && !isSvgOutput + const [viewportDraft, setViewportDraft] = useState({ width: viewportWidth, height: viewportHeight }) + useEffect(() => { + setViewportDraft({ width: viewportWidth, height: viewportHeight }) + }, [viewportWidth, viewportHeight]) + + const onViewportDraftChange = useCallback((field: 'width' | 'height', value: number) => { + setViewportDraft((prev) => ({ ...prev, [field]: Math.min(4000, Math.max(200, value)) })) + }, []) + const onViewportApply = useCallback(() => { + updateData({ viewportWidth: viewportDraft.width, viewportHeight: viewportDraft.height }) + }, [updateData, viewportDraft.width, viewportDraft.height]) return ( @@ -528,20 +549,66 @@ function RenderingNodeComponent({ id, width, height }: Props) { nodeId={id} nodeType="render" nodeMenuExtraContent={ - - - - Export - - - - PNG - - - SVG - - - + <> + {isSvgOutput && ( + + Viewport + +
+
+ + { + const v = parseInt(e.target.value, 10) + if (!Number.isNaN(v)) onViewportDraftChange('width', v) + }} + className="h-7 text-xs" + /> +
+
+ + { + const v = parseInt(e.target.value, 10) + if (!Number.isNaN(v)) onViewportDraftChange('height', v) + }} + className="h-7 text-xs" + /> +
+ +
+
+
+ )} + + + + Export + + + + PNG + + + SVG + + + + } /> @@ -584,19 +651,64 @@ function RenderingNodeComponent({ id, width, height }: Props) { ) : loading ? (
Rendering…
) : renderedContent ? ( - isWireframeOutput ? ( -
-
-
-
+ isSvgOutput ? ( +
+ ref?.centerView(1, 0, 0)} + panning={{ disabled: true }} + wheel={{ disabled: true }} + doubleClick={{ disabled: true }} + > + {({ zoomIn, zoomOut, resetTransform }) => ( + <> +
+ + + +
+
+ +
+ +
+ + )} +
) : (
) diff --git a/src/lib/configTypes.ts b/src/lib/configTypes.ts index ade3c35..2096782 100644 --- a/src/lib/configTypes.ts +++ b/src/lib/configTypes.ts @@ -108,15 +108,20 @@ async function renderMarkdown(content: string): Promise { return typeof html === 'string' ? html : String(html) } -/** Wireweave DSL to HTML+CSS; theme from document dark mode. See https://www.wireweave.org/ */ -async function renderWireframe(content: string): Promise { - const { parse, render } = await import('@wireweave/core') +/** Wireweave DSL to SVG; theme from document dark mode. See https://www.wireweave.org/ */ +async function renderWireframe(content: string, options?: RenderOptions): Promise { + const { parse, renderToSvg } = await import('@wireweave/core') const doc = parse(content) const isDark = typeof document !== 'undefined' && document.documentElement?.classList?.contains('dark') - const { html, css } = render(doc, { theme: isDark ? 'dark' : 'light' }) - return `${html}` + const { svg } = renderToSvg(doc, { + theme: isDark ? 'dark' : 'light', + width: options?.width ?? 1200, + height: options?.height, + padding: 24, + }) + return svg } export const CONFIG_TYPES: ConfigType[] = [ diff --git a/src/lib/registerBuiltinNodes.tsx b/src/lib/registerBuiltinNodes.tsx index 2ac7c16..7cc1ea1 100644 --- a/src/lib/registerBuiltinNodes.tsx +++ b/src/lib/registerBuiltinNodes.tsx @@ -45,7 +45,7 @@ export function registerBuiltinNodes(): void { id: 'render', component: RenderingNode, defaultStyle: { width: 384, height: 320 }, - defaultData: {}, + defaultData: { viewportWidth: 1200, viewportHeight: 800 }, idPrefix: 'rnd_', hasInput: true, hasOutput: false, diff --git a/src/styles.css b/src/styles.css index a723826..1277ec7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -56,6 +56,15 @@ body { pointer-events: none; } +/* Viewport control buttons: same look as React Flow controls; Lucide icons use stroke. */ +.rendering-viewport .react-flow__controls-button svg { + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + /* Rendering node: diagram/wireframe SVG must fit inside the content area. */ .rendering-diagram { display: flex;