feat: refactoring rendering
This commit is contained in:
@@ -35,7 +35,6 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import { outputMenuActionRequiresSvg } from '@/lib/graph/configTypes'
|
||||
import {
|
||||
useRenderingNodeState,
|
||||
type RenderingNodeData,
|
||||
@@ -266,44 +265,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
>
|
||||
Raw
|
||||
</MenubarCheckboxItem>
|
||||
{state.outputMenuDescriptor && state.outputMenuDescriptor.items.length > 0 && (
|
||||
<>
|
||||
<MenubarSeparator />
|
||||
<MenubarSub>
|
||||
<MenubarSubTrigger
|
||||
className="text-xs"
|
||||
disabled={state.outputMenuDescriptor.items.some(
|
||||
(it) => outputMenuActionRequiresSvg(it.action) && !state.isSvgOutput
|
||||
)}
|
||||
>
|
||||
{state.outputMenuDescriptor.submenuLabel ?? 'Output'}
|
||||
</MenubarSubTrigger>
|
||||
<MenubarSubContent className="min-w-[10rem]" aria-label={state.outputMenuDescriptor.submenuLabel ?? 'Output'}>
|
||||
{state.outputMenuDescriptor.items.map((item) => {
|
||||
const disabled = outputMenuActionRequiresSvg(item.action) && !state.isSvgOutput
|
||||
const onClick =
|
||||
item.action === 'downloadSvg'
|
||||
? state.downloadSvg
|
||||
: item.action === 'downloadPng'
|
||||
? state.downloadPng
|
||||
: item.action === 'copySvg'
|
||||
? state.copySvg
|
||||
: state.copyPng
|
||||
return (
|
||||
<MenubarItem
|
||||
key={item.id}
|
||||
className="text-xs"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{item.label}
|
||||
</MenubarItem>
|
||||
)
|
||||
})}
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
</>
|
||||
)}
|
||||
{getNodeType(state.sourceNodeType ?? '')?.getOutputMenuContent?.(state.rawLanguage, { state, nodeId: id })}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export { default as RenderingNode, type RenderingNodeData } from './RenderingNode'
|
||||
export { getRenderNodeDescriptor } from './descriptor'
|
||||
export { createImageExportHandlers } from './outputMenuRegistry'
|
||||
export type { OutputMenuContext } from './outputMenuRegistry'
|
||||
|
||||
73
frontend/src/components/nodes/render/outputMenuRegistry.tsx
Normal file
73
frontend/src/components/nodes/render/outputMenuRegistry.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Shared output menu helpers for the Rendering node. Source node types (e.g. config)
|
||||
* define their Output menu content in their descriptor via getOutputMenuContent; they
|
||||
* use createImageExportHandlers and this context type for image export (SVG/PNG).
|
||||
*/
|
||||
|
||||
import type { RenderingNodeState } from './useRenderingNodeState'
|
||||
|
||||
export type OutputMenuContext = {
|
||||
state: RenderingNodeState
|
||||
nodeId: string
|
||||
}
|
||||
|
||||
/** Create handlers for SVG export (download SVG/PNG, copy). Used by source descriptors (e.g. config). */
|
||||
export function createImageExportHandlers(ctx: OutputMenuContext) {
|
||||
const { state, nodeId } = ctx
|
||||
const { renderedContent, isSvgOutput } = state
|
||||
return {
|
||||
downloadSvg: () => {
|
||||
if (!renderedContent || !isSvgOutput) return
|
||||
const blob = new Blob([renderedContent], { type: 'image/svg+xml' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${nodeId}.svg`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
downloadPng: () => {
|
||||
if (!renderedContent || !isSvgOutput) return
|
||||
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(renderedContent)
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
const c = canvas.getContext('2d')
|
||||
if (!c) return
|
||||
c.drawImage(img, 0, 0)
|
||||
const a = document.createElement('a')
|
||||
a.href = canvas.toDataURL('image/png')
|
||||
a.download = `${nodeId}.png`
|
||||
a.click()
|
||||
}
|
||||
img.onerror = () => {}
|
||||
img.src = dataUrl
|
||||
},
|
||||
copySvg: () => {
|
||||
if (!renderedContent || !isSvgOutput) return
|
||||
const blob = new Blob([renderedContent], { type: 'image/svg+xml' })
|
||||
navigator.clipboard?.write([new ClipboardItem({ 'image/svg+xml': blob })]).catch(() => {})
|
||||
},
|
||||
copyPng: () => {
|
||||
if (!renderedContent || !isSvgOutput) return
|
||||
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(renderedContent)
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
const c = canvas.getContext('2d')
|
||||
if (!c) return
|
||||
c.drawImage(img, 0, 0)
|
||||
canvas.toBlob(
|
||||
(blob) => blob && navigator.clipboard?.write([new ClipboardItem({ 'image/png': blob })]).catch(() => {}),
|
||||
'image/png'
|
||||
)
|
||||
}
|
||||
img.onerror = () => {}
|
||||
img.src = dataUrl
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,26 @@
|
||||
/**
|
||||
* All logic for the Rendering node: source resolution, signatures, run effect,
|
||||
* streaming, and derived display state. The RenderingNode UI is kept dumb and
|
||||
* only consumes this hook’s return value.
|
||||
* runs the resolve → render pipeline, manages streaming/cache,
|
||||
* and exposes derived state for the dumb UI. See lib/graph/rendering.ts for the
|
||||
* pipeline interface.
|
||||
*/
|
||||
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useAbstractNode } from '@/lib/graph/abstractNode'
|
||||
import { getConfigContent, getConfigType, getConfigTypeId } from '@/lib/graph/configTypes'
|
||||
import type { ConfigTypeId, OutputMenuDescriptor } from '@/lib/graph/configTypes'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { getSourceRenderingLogic, type SourceRenderingLogicContext } from '@/lib/graph/sourceRenderingLogic'
|
||||
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
||||
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
||||
import { parseThinkSections, processSvgDisplay, stripTemplateSyntax } from '@/lib/graph/renderingUtils'
|
||||
import {
|
||||
getConfigContent,
|
||||
getConfigType,
|
||||
getConfigTypeId,
|
||||
getSourceRenderingLogic,
|
||||
parseThinkSections,
|
||||
processSvgDisplay,
|
||||
stripTemplateSyntax,
|
||||
} from '@/lib/graph/rendering'
|
||||
import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
|
||||
|
||||
export type RenderingNodeData = {
|
||||
viewportWidth?: number
|
||||
@@ -67,20 +73,14 @@ export type RenderingNodeState = {
|
||||
viewportWidth: number
|
||||
viewportHeight: number
|
||||
|
||||
// Callbacks
|
||||
downloadSvg: () => void
|
||||
downloadPng: () => void
|
||||
copySvg: () => void
|
||||
copyPng: () => void
|
||||
|
||||
/** Output menu descriptor from the source (e.g. Export submenu for image config types). Null when no source or source provides none. */
|
||||
outputMenuDescriptor: OutputMenuDescriptor | null
|
||||
|
||||
// Empty state (source-type-specific action stays in hook)
|
||||
emptyStateAction: null | { label: string; onClick: () => void }
|
||||
|
||||
// Optional custom error UI from source node data
|
||||
sourceData: Record<string, unknown>
|
||||
|
||||
/** Source node type id (e.g. 'config', 'agent') for Output menu from descriptor. */
|
||||
sourceNodeType: string | null
|
||||
}
|
||||
|
||||
export function useRenderingNodeState(
|
||||
@@ -363,6 +363,7 @@ export function useRenderingNodeState(
|
||||
cachedReasoningContent: undefined,
|
||||
})
|
||||
try {
|
||||
// Pipeline: 1) Resolve (source) → 2) Render (output type) → 3) Cache
|
||||
const { nodes: ctxNodes, edges: ctxEdges } = nodesEdgesRef.current
|
||||
const context = {
|
||||
nodes: ctxNodes,
|
||||
@@ -518,10 +519,6 @@ export function useRenderingNodeState(
|
||||
const isSvgOutput = Boolean(
|
||||
renderedContent?.trim() && /<svg[\s>]/i.test(renderedContent.trim())
|
||||
)
|
||||
const outputMenuDescriptor = useMemo(
|
||||
() => sourceLogic?.getOutputMenuDescriptor?.(configTypeId) ?? null,
|
||||
[sourceLogic, configTypeId]
|
||||
)
|
||||
const renderedThinkSplit = useMemo(() => {
|
||||
if (outputType === 'image' || !renderedContent) return { main: '', think: '' }
|
||||
return parseThinkSections(renderedContent)
|
||||
@@ -540,64 +537,6 @@ export function useRenderingNodeState(
|
||||
return processSvgDisplay(renderedContent)
|
||||
}, [renderedContent, isSvgOutput])
|
||||
|
||||
const downloadSvg = useCallback(() => {
|
||||
if (!renderedContent || !isSvgOutput) return
|
||||
const blob = new Blob([renderedContent], { type: 'image/svg+xml' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${id}.svg`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}, [id, renderedContent, isSvgOutput])
|
||||
|
||||
const downloadPng = useCallback(() => {
|
||||
if (!renderedContent || !isSvgOutput) return
|
||||
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(renderedContent)
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const pngUrl = canvas.toDataURL('image/png')
|
||||
const a = document.createElement('a')
|
||||
a.href = pngUrl
|
||||
a.download = `${id}.png`
|
||||
a.click()
|
||||
}
|
||||
img.onerror = () => {}
|
||||
img.src = dataUrl
|
||||
}, [id, renderedContent, isSvgOutput])
|
||||
|
||||
const copyPng = useCallback(() => {
|
||||
if (!renderedContent || !isSvgOutput) return
|
||||
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(renderedContent)
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.drawImage(img, 0, 0)
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob)
|
||||
navigator.clipboard?.write([new ClipboardItem({ 'image/png': blob })]).catch(() => {})
|
||||
}, 'image/png')
|
||||
}
|
||||
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(() => {})
|
||||
}, [renderedContent, isSvgOutput])
|
||||
|
||||
const incrementRunTrigger = useCallback(() => {
|
||||
updateData({ runTrigger: (data?.runTrigger ?? 0) + 1 })
|
||||
}, [data?.runTrigger, updateData])
|
||||
@@ -644,6 +583,7 @@ export function useRenderingNodeState(
|
||||
}, [id, incomingIds.length, nodes, setNodes, setEdges])
|
||||
|
||||
const sourceData = useMemo(() => (srcNode?.data as Record<string, unknown>) ?? {}, [srcNode?.data])
|
||||
const sourceNodeType = (srcNode?.type as string) ?? null
|
||||
|
||||
return {
|
||||
incomingIds,
|
||||
@@ -670,13 +610,9 @@ export function useRenderingNodeState(
|
||||
displayContent,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
downloadSvg,
|
||||
downloadPng,
|
||||
copySvg,
|
||||
copyPng,
|
||||
setUpdateMode,
|
||||
outputMenuDescriptor,
|
||||
emptyStateAction,
|
||||
sourceData,
|
||||
sourceNodeType,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* Output view components used by the Rendering node.
|
||||
* Which view is shown is determined by the source's outputType (image vs html);
|
||||
* the actual HTML structure lives here so it is not coupled to the Rendering node.
|
||||
* Output view components: "Display" step of the pipeline (see lib/graph/rendering.ts).
|
||||
* Which view is used comes from the source's outputType ('image' | 'html').
|
||||
*/
|
||||
|
||||
export { ImageOutputView } from './ImageOutputView'
|
||||
|
||||
Reference in New Issue
Block a user