From 5a0a2b7e44b7a0adc81910014b415e15822ab7ad Mon Sep 17 00:00:00 2001 From: Ganondorf <364776488@qq.com> Date: Sat, 9 Aug 2025 15:31:32 +0800 Subject: [PATCH] Allow to export full screen image of workflow (#23655) --- .../workflow/operator/export-image.tsx | 258 +++++++++++++----- web/i18n/en-US/workflow.ts | 2 + web/i18n/ja-JP/workflow.ts | 2 + web/i18n/zh-Hans/workflow.ts | 2 + 4 files changed, 202 insertions(+), 62 deletions(-) diff --git a/web/app/components/workflow/operator/export-image.tsx b/web/app/components/workflow/operator/export-image.tsx index f59f0cd92..546c702d6 100644 --- a/web/app/components/workflow/operator/export-image.tsx +++ b/web/app/components/workflow/operator/export-image.tsx @@ -16,15 +16,20 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' +import { getNodesBounds, useReactFlow } from 'reactflow' +import ImagePreview from '@/app/components/base/image-uploader/image-preview' const ExportImage: FC = () => { const { t } = useTranslation() const { getNodesReadOnly } = useNodesReadOnly() + const reactFlow = useReactFlow() const appDetail = useAppStore(s => s.appDetail) const [open, setOpen] = useState(false) + const [previewUrl, setPreviewUrl] = useState('') + const [previewTitle, setPreviewTitle] = useState('') - const handleExportImage = useCallback(async (type: 'png' | 'jpeg' | 'svg') => { + const handleExportImage = useCallback(async (type: 'png' | 'jpeg' | 'svg', currentWorkflow = false) => { if (!appDetail) return @@ -44,31 +49,123 @@ const ExportImage: FC = () => { } let dataUrl - switch (type) { - case 'png': - dataUrl = await toPng(flowElement, { filter }) - break - case 'jpeg': - dataUrl = await toJpeg(flowElement, { filter }) - break - case 'svg': - dataUrl = await toSvg(flowElement, { filter }) - break - default: - dataUrl = await toPng(flowElement, { filter }) + let filename = `${appDetail.name}` + + if (currentWorkflow) { + // Get all nodes and their bounds + const nodes = reactFlow.getNodes() + const nodesBounds = getNodesBounds(nodes) + + // Save current viewport + const currentViewport = reactFlow.getViewport() + + // Calculate the required zoom to fit all nodes + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + const zoom = Math.min( + viewportWidth / (nodesBounds.width + 100), + viewportHeight / (nodesBounds.height + 100), + 1, + ) + + // Calculate center position + const centerX = nodesBounds.x + nodesBounds.width / 2 + const centerY = nodesBounds.y + nodesBounds.height / 2 + + // Set viewport to show all nodes + reactFlow.setViewport({ + x: viewportWidth / 2 - centerX * zoom, + y: viewportHeight / 2 - centerY * zoom, + zoom, + }) + + // Wait for the transition to complete + await new Promise(resolve => setTimeout(resolve, 300)) + + // Calculate actual content size with padding + const padding = 50 // More padding for better visualization + const contentWidth = nodesBounds.width + padding * 2 + const contentHeight = nodesBounds.height + padding * 2 + + // Export with higher quality for whole workflow + const exportOptions = { + filter, + backgroundColor: '#1a1a1a', // Dark background to match previous style + pixelRatio: 2, // Higher resolution for better zoom + width: contentWidth, + height: contentHeight, + style: { + width: `${contentWidth}px`, + height: `${contentHeight}px`, + transform: `translate(${padding - nodesBounds.x}px, ${padding - nodesBounds.y}px) scale(${zoom})`, + }, + } + + switch (type) { + case 'png': + dataUrl = await toPng(flowElement, exportOptions) + break + case 'jpeg': + dataUrl = await toJpeg(flowElement, exportOptions) + break + case 'svg': + dataUrl = await toSvg(flowElement, { filter }) + break + default: + dataUrl = await toPng(flowElement, exportOptions) + } + + filename += '-whole-workflow' + + // Restore original viewport after a delay + setTimeout(() => { + reactFlow.setViewport(currentViewport) + }, 500) + } + else { + // Current viewport export (existing functionality) + switch (type) { + case 'png': + dataUrl = await toPng(flowElement, { filter }) + break + case 'jpeg': + dataUrl = await toJpeg(flowElement, { filter }) + break + case 'svg': + dataUrl = await toSvg(flowElement, { filter }) + break + default: + dataUrl = await toPng(flowElement, { filter }) + } } - const link = document.createElement('a') - link.href = dataUrl - link.download = `${appDetail.name}.${type}` - document.body.appendChild(link) - link.click() - document.body.removeChild(link) + if (currentWorkflow) { + // For whole workflow, show preview first + setPreviewUrl(dataUrl) + setPreviewTitle(`${filename}.${type}`) + + // Also auto-download + const link = document.createElement('a') + link.href = dataUrl + link.download = `${filename}.${type}` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + else { + // For current view, just download + const link = document.createElement('a') + link.href = dataUrl + link.download = `${filename}.${type}` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } } catch (error) { console.error('Export image failed:', error) } - }, [getNodesReadOnly, appDetail]) + }, [getNodesReadOnly, appDetail, reactFlow]) const handleTrigger = useCallback(() => { if (getNodesReadOnly()) @@ -78,53 +175,90 @@ const ExportImage: FC = () => { }, [getNodesReadOnly]) return ( - - - -
- -
-
-
- -
-
+ <> + + +
handleExportImage('png')} + className={cn( + 'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary', + `${getNodesReadOnly() && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`, + )} + onClick={handleTrigger} > - {t('workflow.common.exportPNG')} +
-
handleExportImage('jpeg')} - > - {t('workflow.common.exportJPEG')} -
-
handleExportImage('svg')} - > - {t('workflow.common.exportSVG')} + + + +
+
+
+ {t('workflow.common.currentView')} +
+
handleExportImage('png')} + > + {t('workflow.common.exportPNG')} +
+
handleExportImage('jpeg')} + > + {t('workflow.common.exportJPEG')} +
+
handleExportImage('svg')} + > + {t('workflow.common.exportSVG')} +
+ +
+ +
+ {t('workflow.common.currentWorkflow')} +
+
handleExportImage('png', true)} + > + {t('workflow.common.exportPNG')} +
+
handleExportImage('jpeg', true)} + > + {t('workflow.common.exportJPEG')} +
+
handleExportImage('svg', true)} + > + {t('workflow.common.exportSVG')} +
-
-
- + + + + {previewUrl && ( + setPreviewUrl('')} + /> + )} + ) } diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 2653303e6..467044d89 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -74,6 +74,8 @@ const translation = { exportPNG: 'Export as PNG', exportJPEG: 'Export as JPEG', exportSVG: 'Export as SVG', + currentView: 'Current View', + currentWorkflow: 'Current Workflow', model: 'Model', workflowAsTool: 'Workflow as Tool', configureRequired: 'Configure Required', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index b447bff2b..30b914da3 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -74,6 +74,8 @@ const translation = { exportPNG: 'PNG で出力', exportJPEG: 'JPEG で出力', exportSVG: 'SVG で出力', + currentView: '現在のビュー', + currentWorkflow: '現在のワークフロー', model: 'モデル', workflowAsTool: 'ワークフローをツールとして公開する', configureRequired: '設定が必要', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index e18c59730..6a74dc7e0 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -73,6 +73,8 @@ const translation = { exportPNG: '导出为 PNG', exportJPEG: '导出为 JPEG', exportSVG: '导出为 SVG', + currentView: '当前视图', + currentWorkflow: '整个工作流', model: '模型', workflowAsTool: '发布为工具', configureRequired: '需要进行配置',