Skip to content

Commit

Permalink
Added support for Download as SVG image for charts
Browse files Browse the repository at this point in the history
  • Loading branch information
kraken77 authored and nexovec committed Oct 10, 2024
1 parent d590382 commit c57f113
Show file tree
Hide file tree
Showing 4 changed files with 138 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
import { NoAnimationDropdown } from 'src/components/Dropdown';
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
import downloadAsImage from 'src/utils/downloadAsImage';
import downloadAsImageSVG from 'src/utils/downloadAsImageSVG';
import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip';
import { Tooltip } from 'src/components/Tooltip';
import Icons from 'src/components/Icons';
Expand Down Expand Up @@ -652,6 +653,31 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => {
});
break;
}
case MenuKeys.DownloadAsImageSVG: {
// menu closes with a delay, we need to hide it manually,
// so that we don't capture it on the screenshot
const menu = document.querySelector(
'.ant-dropdown:not(.ant-dropdown-hidden)',
) as HTMLElement;
if (menu) {
menu.style.visibility = 'hidden';
}
downloadAsImageSVG(
getScreenshotNodeSelector(props.slice.slice_id),
props.slice.slice_name,
true,
// @ts-ignore
)(domEvent).then(() => {
if (menu) {
menu.style.visibility = 'visible';
}
});
props.logEvent?.(LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, {
chartId: props.slice.slice_id,
});
break;
}

case MenuKeys.CrossFilterScoping: {
openScopingModal();
break;
Expand Down Expand Up @@ -912,6 +938,13 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => {
>
{t('Download as image')}
</Menu.Item>

<Menu.Item
key={MenuKeys.DownloadAsImageSVG}
icon={<Icons.FileImageOutlined css={dropdownIconsStyles} />}
>
{t('Download as image SVG')}
</Menu.Item>
</Menu.SubMenu>
)}
</Menu>
Expand Down
1 change: 1 addition & 0 deletions superset-frontend/src/dashboard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ export type Slice = {

export enum MenuKeys {
DownloadAsImage = 'download_as_image',
DownloadAsImageSVG = 'download_as_image_svg',
ExploreChart = 'explore_chart',
ExportCsv = 'export_csv',
ExportPivotCsv = 'export_pivot_csv',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import Button from 'src/components/Button';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { exportChart, getChartKey } from 'src/explore/exploreUtils';
import downloadAsImage from 'src/utils/downloadAsImage';
import downloadAsImageSVG from 'src/utils/downloadAsImageSVG';
import { getChartPermalink } from 'src/utils/urlUtils';
import copyTextToClipboard from 'src/utils/copy';
import HeaderReportDropDown from 'src/features/reports/ReportModal/HeaderReportDropdown';
Expand All @@ -57,6 +58,7 @@ const MENU_KEYS = {
EXPORT_TO_JSON: 'export_to_json',
EXPORT_TO_XLSX: 'export_to_xlsx',
DOWNLOAD_AS_IMAGE: 'download_as_image',
DOWNLOAD_AS_IMAGE_SVG: 'download_as_image_svg',
SHARE_SUBMENU: 'share_submenu',
COPY_PERMALINK: 'copy_permalink',
EMBED_CODE: 'embed_code',
Expand Down Expand Up @@ -269,6 +271,22 @@ export const useExploreAdditionalActionsMenu = (
}),
);
break;
case MENU_KEYS.DOWNLOAD_AS_IMAGE_SVG:
downloadAsImageSVG(
'.panel-body .chart-container',
// eslint-disable-next-line camelcase
slice?.slice_name ?? t('New chart'),
true,
)(domEvent);
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, {
chartId: slice?.slice_id,
chartName: slice?.slice_name,
}),
);
break;

case MENU_KEYS.COPY_PERMALINK:
copyLink();
setIsDropdownVisible(false);
Expand Down
86 changes: 86 additions & 0 deletions superset-frontend/src/utils/downloadAsImageSVG.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SyntheticEvent } from 'react';
import domToImage from 'dom-to-image-more';
import { kebabCase } from 'lodash';
import { t, supersetTheme } from '@superset-ui/core';
import { addWarningToast } from 'src/components/MessageToasts/actions';

/**
* generate a consistent file stem from a description and date
*
* @param description title or description of content of file
* @param date date when file was generated
*/
const generateFileStem = (description: string, date = new Date()) =>
`${kebabCase(description)}-${date.toISOString().replace(/[: ]/g, '-')}`;

/**
* Create an event handler for turning an element into an image
*
* @param selector css selector of the parent element which should be turned into image
* @param description name or a short description of what is being printed.
* Value will be normalized, and a date as well as a file extension will be added.
* @param isExactSelector if false, searches for the closest ancestor that matches selector.
* @returns event handler
*/
export default function downloadAsImage(
selector: string,
description: string,
isExactSelector = false,
) {
return (event: SyntheticEvent) => {
const elementToPrint = isExactSelector
? document.querySelector(selector)
: event.currentTarget.closest(selector);

if (!elementToPrint) {
return addWarningToast(
t('Image download failed, please refresh and try again.'),
);
}

// Mapbox controls are loaded from different origin, causing CORS error
// See https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toDataURL#exceptions
const filter = (node: Element) => {
if (typeof node.className === 'string') {
return (
node.className !== 'mapboxgl-control-container' &&
!node.className.includes('header-controls')
);
}
return true;
};

return domToImage
.toSvg(elementToPrint, {
bgcolor: supersetTheme.colors.grayscale.light4,
filter,
})
.then((dataUrl: string) => {
const link = document.createElement('a');
link.download = `${generateFileStem(description)}.jpg`;
link.href = dataUrl;
link.click();
})
.catch((e: Error) => {
console.error('Creating image failed', e);
});
};
}

0 comments on commit c57f113

Please sign in to comment.