blob: e8e36bc27e8839d1bacdcc6ed39554088341b41f [file] [log] [blame]
/**
* 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';
// eslint-disable-next-line no-restricted-imports
import { SupersetTheme, t } from '@superset-ui/core';
import { addWarningToast } from 'src/components/MessageToasts/actions';
const IMAGE_DOWNLOAD_QUALITY = 0.95;
/**
* 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, '-')}`;
const CRITICAL_STYLE_PROPERTIES = new Set([
'display',
'position',
'width',
'height',
'max-width',
'max-height',
'margin',
'padding',
'top',
'right',
'bottom',
'left',
'font',
'font-family',
'font-size',
'font-weight',
'font-style',
'line-height',
'letter-spacing',
'word-spacing',
'text-align',
'text-decoration',
'color',
'background-color',
'border',
'border-width',
'border-style',
'border-color',
'opacity',
'visibility',
'overflow',
'z-index',
'transform',
'flex',
'flex-direction',
'justify-content',
'align-items',
'grid',
'grid-template',
'grid-area',
'table-layout',
'vertical-align',
'text-align',
]);
const styleCache = new WeakMap<Element, CSSStyleDeclaration>();
const copyAllComputedStyles = (original: Element, clone: Element) => {
const queue: Array<[Element, Element]> = [[original, clone]];
const processed = new WeakSet<Element>();
while (queue.length) {
const [origNode, cloneNode] = queue.shift()!;
if (processed.has(origNode)) continue;
processed.add(origNode);
let computed = styleCache.get(origNode);
if (!computed) {
computed = window.getComputedStyle(origNode);
styleCache.set(origNode, computed);
}
for (const property of CRITICAL_STYLE_PROPERTIES) {
const value = computed.getPropertyValue(property);
if (value && value !== 'initial' && value !== 'inherit') {
(cloneNode as HTMLElement).style.setProperty(
property,
value,
computed.getPropertyPriority(property),
);
}
}
if (origNode.textContent?.trim()) {
const { color } = computed;
if (!color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)') {
(cloneNode as HTMLElement).style.color = '#000';
}
(cloneNode as HTMLElement).style.visibility = 'visible';
if (computed.display === 'none') {
(cloneNode as HTMLElement).style.display = 'block';
}
}
for (let i = 0; i < origNode.children.length; i += 1) {
queue.push([origNode.children[i], cloneNode.children[i]]);
}
}
};
const processCloneForVisibility = (clone: HTMLElement) => {
const cloneStyle = clone.style;
cloneStyle.height = 'auto';
cloneStyle.maxHeight = 'none';
const scrollableSelectors = [
'[style*="overflow"]',
'.scrollable',
'.table-responsive',
'.ant-table-body',
'.table-container',
'.ant-table-container',
'.table-wrapper',
'.ant-table-tbody',
'tbody',
'.table-body',
'.virtual-table',
'.react-window',
'.react-virtualized',
];
scrollableSelectors.forEach(selector => {
clone.querySelectorAll(selector).forEach(el => {
const element = el as HTMLElement;
element.style.overflow = 'visible';
element.style.height = 'auto';
element.style.maxHeight = 'none';
});
});
clone
.querySelectorAll('table, .ant-table, .table-container, .data-table')
.forEach(table => {
const el = table as HTMLElement;
el.style.margin = '0 auto';
el.style.display = 'table';
el.style.width = '100%';
el.style.tableLayout = 'auto';
});
clone
.querySelectorAll('tr, .ant-table-row, .table-row, .data-row')
.forEach(row => {
const el = row as HTMLElement;
el.style.display = 'table-row';
el.style.visibility = 'visible';
el.style.height = 'auto';
});
clone
.querySelectorAll('td, th, .ant-table-cell, .table-cell')
.forEach(cell => {
const el = cell as HTMLElement;
el.style.display = 'table-cell';
el.style.visibility = 'visible';
});
clone.querySelectorAll('*').forEach(el => {
const element = el as HTMLElement;
if (element.textContent?.trim()) {
const computed = window.getComputedStyle(element);
if (computed.color === 'transparent') {
element.style.color = '#000';
}
element.style.visibility = 'visible';
if (computed.display === 'none') {
element.style.display = 'block';
}
}
});
clone
.querySelectorAll('[data-virtualized], .virtualized, .lazy-load')
.forEach(el => {
const element = el as HTMLElement;
element.style.height = 'auto';
element.style.maxHeight = 'none';
});
};
const createEnhancedClone = (
originalElement: Element,
): { clone: HTMLElement; cleanup: () => void } => {
const clone = originalElement.cloneNode(true) as HTMLElement;
copyAllComputedStyles(originalElement, clone);
const tempContainer = document.createElement('div');
tempContainer.style.cssText = `
position: absolute;
left: -20000px;
top: -20000px;
visibility: hidden;
pointer-events: none;
z-index: -1000;
`;
tempContainer.appendChild(clone);
document.body.appendChild(tempContainer);
processCloneForVisibility(clone);
const cleanup = () => {
styleCache.delete?.(originalElement);
if (tempContainer.parentElement) {
tempContainer.parentElement.removeChild(tempContainer);
}
};
return { clone, cleanup };
};
export default function downloadAsImageOptimized(
selector: string,
description: string,
isExactSelector = false,
theme?: SupersetTheme,
) {
return async (event: SyntheticEvent) => {
const elementToPrint = isExactSelector
? document.querySelector(selector)
: event.currentTarget.closest(selector);
if (!elementToPrint) {
addWarningToast(
t('Image download failed, please refresh and try again.'),
);
return;
}
let cleanup: (() => void) | null = null;
try {
const { clone, cleanup: cleanupFn } = createEnhancedClone(elementToPrint);
cleanup = cleanupFn;
const filter = (node: Element) =>
typeof node.className === 'string'
? !node.className.includes('mapboxgl-control-container') &&
!node.className.includes('header-controls')
: true;
const dataUrl = await domToImage.toJpeg(clone, {
bgcolor: theme?.colorBgContainer,
filter,
quality: IMAGE_DOWNLOAD_QUALITY,
height: clone.scrollHeight,
width: clone.scrollWidth,
cacheBust: true,
});
cleanup();
cleanup = null;
const link = document.createElement('a');
link.download = `${generateFileStem(description)}.jpg`;
link.href = dataUrl;
link.click();
} catch (error) {
console.error('Creating image failed', error);
addWarningToast(
t('Image download failed, please refresh and try again.'),
);
} finally {
if (cleanup) cleanup();
}
};
}