diff --git a/apps/web/src/components/v2Editor/customBlocks/python/PythonOutput.tsx b/apps/web/src/components/v2Editor/customBlocks/python/PythonOutput.tsx index acb2c50..655e143 100644 --- a/apps/web/src/components/v2Editor/customBlocks/python/PythonOutput.tsx +++ b/apps/web/src/components/v2Editor/customBlocks/python/PythonOutput.tsx @@ -12,6 +12,7 @@ import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/20/solid' import createDomPurify, { DOMPurifyI } from 'dompurify' import React, { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import useResettableState from '@/hooks/useResettableState' +import { downloadFile } from '@/utils/file' import debounce from 'lodash.debounce' import { PythonBlock } from '@briefer/editor' @@ -24,6 +25,7 @@ interface Props { isPDF: boolean isDashboardView: boolean lazyRender: boolean + blockId: string } let domPurify: DOMPurifyI @@ -80,6 +82,7 @@ export function PythonOutputs(props: Props) { key={i} className={clsx( ['plotly'].includes(output.type) ? 'flex-grow' : '', + !props.isDashboardView ? 'flex flex-col items-end' : '', 'bg-white overflow-x-scroll' )} > @@ -90,6 +93,7 @@ export function PythonOutputs(props: Props) { isPDF={props.isPDF} canFixWithAI={props.canFixWithAI} isDashboardView={props.isDashboardView} + blockId={props.blockId} /> ))} @@ -104,18 +108,38 @@ interface ItemProps { isPDF: boolean isDashboardView: boolean canFixWithAI: boolean + blockId: string } export function PythonOutput(props: ItemProps) { + const onExportToPNG = () => { + if (props.output.type !== 'image' || props.output.format !== 'png') return + + downloadFile( + `data:image/${props.output.format};base64, ${props.output.data}`, + props.blockId + ) + } + switch (props.output.type) { case 'image': switch (props.output.format) { case 'png': return ( - generated image + <> + generated image + {!props.isDashboardView && ( + + )} + ) } case 'stdio': diff --git a/apps/web/src/components/v2Editor/customBlocks/python/index.tsx b/apps/web/src/components/v2Editor/customBlocks/python/index.tsx index 647072d..7fac54d 100644 --- a/apps/web/src/components/v2Editor/customBlocks/python/index.tsx +++ b/apps/web/src/components/v2Editor/customBlocks/python/index.tsx @@ -277,6 +277,7 @@ function PythonBlock(props: Props) { isDashboardView={props.dashboardPlace === 'view'} lazyRender={props.dashboardPlace === 'controls'} canFixWithAI={hasOaiKey} + blockId={blockId} /> ) } @@ -463,6 +464,7 @@ function PythonBlock(props: Props) { isPDF={props.isPDF} isDashboardView={false} lazyRender={!props.isPDF} + blockId={blockId} /> diff --git a/apps/web/src/components/v2Editor/customBlocks/visualization/VisualizationView.tsx b/apps/web/src/components/v2Editor/customBlocks/visualization/VisualizationView.tsx index 170e745..5cc7b44 100644 --- a/apps/web/src/components/v2Editor/customBlocks/visualization/VisualizationView.tsx +++ b/apps/web/src/components/v2Editor/customBlocks/visualization/VisualizationView.tsx @@ -39,6 +39,7 @@ interface Props { renderer?: 'canvas' | 'svg' isHidden: boolean onToggleHidden: () => void + onExportToPNG?: () => void isDashboard: boolean isEditable: boolean } @@ -153,6 +154,16 @@ function VisualizationView(props: Props) { )} )} + {!props.isDashboard && + props.chartType !== 'number' && + props.chartType !== 'trend' && ( + + )} ) } diff --git a/apps/web/src/components/v2Editor/customBlocks/visualization/index.tsx b/apps/web/src/components/v2Editor/customBlocks/visualization/index.tsx index 22abb04..480c309 100644 --- a/apps/web/src/components/v2Editor/customBlocks/visualization/index.tsx +++ b/apps/web/src/components/v2Editor/customBlocks/visualization/index.tsx @@ -38,6 +38,7 @@ import { VisualizationExecTooltip } from '../../ExecTooltip' import useFullScreenDocument from '@/hooks/useFullScreenDocument' import HiddenInPublishedButton from '../../HiddenInPublishedButton' import useEditorAwareness from '@/hooks/useEditorAwareness' +import { downloadFile } from '@/utils/file' function didChangeFilters( oldFilters: VisualizationFilter[], @@ -202,6 +203,34 @@ function VisualizationBlock(props: Props) { props.block.setAttribute('controlsHidden', !controlsHidden) }, [controlsHidden, props.block]) + const onExportToPNG = async () => { + // we don't need to check if props.renderer is undefined because the application sets as 'canvas' in this case + if ( + props.renderer === 'svg' || + chartType === 'number' || + chartType === 'trend' + ) + return + + // if the controls are visible the canvas shrinks, making the export smaller + if (!controlsHidden) { + onToggleHidden() + // tick to ensure the canvas size gets updated + await new Promise((r) => setTimeout(r, 0)) + } + + const canvas = document.querySelector( + `div[data-block-id='${blockId}'] canvas` + ) as HTMLCanvasElement + + // TODO: identify when this is true + if (!canvas) return + + const imageUrl = canvas.toDataURL('image/png') + const fileName = title || 'Visualization' + downloadFile(imageUrl, fileName) + } + const onChangeChartType = useCallback( (chartType: ChartType) => { props.block.setAttribute('chartType', chartType) @@ -426,6 +455,7 @@ function VisualizationBlock(props: Props) { renderer={props.renderer} isHidden={controlsHidden} onToggleHidden={onToggleHidden} + onExportToPNG={onExportToPNG} isDashboard={props.isDashboard} isEditable={isEditable} /> @@ -555,6 +585,7 @@ function VisualizationBlock(props: Props) { renderer={props.renderer} isHidden={controlsHidden} onToggleHidden={onToggleHidden} + onExportToPNG={onExportToPNG} isDashboard={props.isDashboard} isEditable={isEditable} /> diff --git a/apps/web/src/utils/file.ts b/apps/web/src/utils/file.ts index c8d165b..e71ba63 100644 --- a/apps/web/src/utils/file.ts +++ b/apps/web/src/utils/file.ts @@ -19,3 +19,14 @@ export function readFile( } }) } + +export function downloadFile(url: string, name: string) { + const downloadLink = document.createElement('a') + + downloadLink.download = name + downloadLink.href = url + + document.body.appendChild(downloadLink) + downloadLink.click() + document.body.removeChild(downloadLink) +}