[CB-5925] Charts - add export option (image) (#3819)

* dbeaver/pro#5078 adds chart export

* adds ExportImageDialog and reuses it across the app

* uses canvas2html for downloading images

* Revert "uses canvas2html for downloading images"

This reverts commit b59374cf85.

* moves transformClassesToStyles to helpers

* transforms styling attributes to computed styles

* adds file name

* fixes translations

* fixes styles for png image so it do not exports in black&white mode with incorrect sizing

* fixes styles for svg export

* uses domToImage library to save chart with legend

* cleanup

* moves helpers back to d3js diagrams

* cleanup

* uses html-to-image instead of dom-to-image

* cleanup

* pr fixes

* replaces the lib

* fixes svg so it is not blur

* validation for image urls

* cleanup

---------

Co-authored-by: Evgenia <139753579+EvgeniaBzzz@users.noreply.github.com>
This commit is contained in:
sergeyteleshev
2025-10-27 17:09:23 +08:00
committed by GitHub
co-authored by Evgenia
parent 046659672c
commit 37cfecbf91
16 changed files with 218 additions and 1 deletions
@@ -0,0 +1,101 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import type { DialogComponentProps } from '@cloudbeaver/core-dialogs';
import { observable } from 'mobx';
import { EXPORT_IMAGE_FORMATS, type ExportImageFormat } from './ExportImageFormats.js';
import { useTranslate } from '../localization/useTranslate.js';
import { useObservableRef } from '../useObservableRef.js';
import { CommonDialogBody } from '../CommonDialog/CommonDialog/CommonDialogBody.js';
import { CommonDialogFooter } from '../CommonDialog/CommonDialog/CommonDialogFooter.js';
import { CommonDialogHeader } from '../CommonDialog/CommonDialog/CommonDialogHeader.js';
import { CommonDialogWrapper } from '../CommonDialog/CommonDialog/CommonDialogWrapper.js';
import { Container } from '../Containers/Container.js';
import { ErrorMessage } from '../ErrorMessage.js';
import { FieldCheckbox } from '../FormControls/Checkboxes/FieldCheckbox.js';
import { InputField } from '../FormControls/InputField/InputField.js';
import { useErrorDetails } from '../useErrorDetails.js';
import { Select } from '../FormControls/Select.js';
import { Button } from '../Button.js';
export interface IExportImageOptions {
format: ExportImageFormat;
transparent: boolean;
fileName: string;
}
export interface ExportImagePayload {
export: (options: IExportImageOptions) => Promise<void>;
defaultFileName: string;
}
interface State extends IExportImageOptions {
error: Error | null;
}
export const ExportImageDialog = observer<DialogComponentProps<ExportImagePayload, null>>(function ExportImageDialog(props) {
const translate = useTranslate();
const state = useObservableRef<State>(
() => ({
format: 'SVG',
transparent: false,
fileName: props.payload.defaultFileName,
error: null,
}),
{
format: observable.ref,
transparent: observable.ref,
fileName: observable.ref,
error: observable.ref,
},
false,
);
const errorDetails = useErrorDetails(state.error);
async function onExportHandler() {
try {
await props.payload.export(state);
props.resolveDialog();
} catch (error: any) {
state.error = error;
}
}
return (
<CommonDialogWrapper size="small">
<CommonDialogHeader title="core_blocks_export_image_dialog_title" onReject={props.rejectDialog} />
<CommonDialogBody>
<Container>
<Container small gap>
<InputField state={state} name="fileName">
{translate('ui_name')}
</InputField>
<Select items={EXPORT_IMAGE_FORMATS} keySelector={value => value} valueSelector={value => value} state={state} name="format">
{translate('core_blocks_export_image_dialog_format')}
</Select>
<FieldCheckbox state={state} name="transparent" label={translate('core_blocks_export_image_dialog_transparent_background')} />
</Container>
</Container>
{errorDetails.name && (
<ErrorMessage text={errorDetails.message || errorDetails.name} hasDetails={errorDetails.hasDetails} onShowDetails={errorDetails.open} />
)}
</CommonDialogBody>
<CommonDialogFooter className="tw:justify-end tw:items-center tw:gap-[24px]">
<Button type="button" variant="secondary" onClick={props.rejectDialog}>
{translate('app_shared_inlineEditor_dialog_cancel')}
</Button>
<Button type="button" onClick={onExportHandler}>
{translate('ui_export')}
</Button>
</CommonDialogFooter>
</CommonDialogWrapper>
);
});
@@ -0,0 +1,11 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { importLazyComponent } from '../importLazyComponent.js';
export const ExportImageDialog = importLazyComponent(() => import('./ExportImageDialog.js').then(m => m.ExportImageDialog));
@@ -0,0 +1,11 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
export type ExportImageFormat = 'SVG' | 'PNG';
export const EXPORT_IMAGE_FORMATS: ExportImageFormat[] = ['SVG', 'PNG'];
+2
View File
@@ -19,6 +19,8 @@ export { default as ConfirmationDialogStyles } from './CommonDialog/Confirmation
export * from './CommonDialog/ConfirmationDialogDelete.js';
export * from './CommonDialog/RenameDialog.js';
export * from './CommonDialog/DialogsPortal.js';
export * from './ExportImageDialog/ExportImageDialogLazy.js';
export * from './ExportImageDialog/ExportImageFormats.js';
export * from './ErrorDetailsDialog/ErrorDetailsDialog.js';
@@ -13,4 +13,7 @@ export default [
['core_blocks_exception_message_error_title', 'Unable to display content'],
['core_blocks_exception_message_error_message', 'Something went wrong.'],
['core_blocks_custom_input_validation_error', 'Field value is not valid'],
['core_blocks_export_image_dialog_title', 'Export Image'],
['core_blocks_export_image_dialog_format', 'File format'],
['core_blocks_export_image_dialog_transparent_background', 'Transparent background'],
];
@@ -12,4 +12,7 @@ export default [
['core_blocks_exception_message_error_title', "Impossible d'afficher le contenu"],
['core_blocks_exception_message_error_message', 'Quelque chose a mal tourné.'],
['core_blocks_custom_input_validation_error', "La valeur du champ n'est pas valide"],
['core_blocks_export_image_dialog_title', "Exporter l'image"],
['core_blocks_export_image_dialog_format', 'Format de fichier'],
['core_blocks_export_image_dialog_transparent_background', 'Fond transparent'],
];
@@ -7,4 +7,7 @@ export default [
['core_blocks_object_property_info_form_empty_placeholder', 'Properties empty'],
['core_blocks_properties_table_item_remove', 'Remove property'],
['core_blocks_custom_input_validation_error', 'Field value is not valid'],
['core_blocks_export_image_dialog_title', 'Esporta immagine'],
['core_blocks_export_image_dialog_format', 'Formato file'],
['core_blocks_export_image_dialog_transparent_background', 'Sfondo trasparente'],
];
@@ -10,4 +10,7 @@ export default [
['combobox_no_results_placeholder', 'Нет совпадений'],
['combobox_select_placeholder', 'Выберите опцию'],
['core_blocks_custom_input_validation_error', 'Значение не валидно'],
['core_blocks_export_image_dialog_title', 'Экспорт изображения'],
['core_blocks_export_image_dialog_format', 'Формат файла'],
['core_blocks_export_image_dialog_transparent_background', 'Прозрачный фон'],
];
@@ -13,4 +13,7 @@ export default [
['core_blocks_exception_message_error_title', 'Không thể hiển thị nội dung'],
['core_blocks_exception_message_error_message', 'Đã xảy ra lỗi.'],
['core_blocks_custom_input_validation_error', 'Giá trị trường không hợp lệ'],
['core_blocks_export_image_dialog_title', 'Xuất hình ảnh'],
['core_blocks_export_image_dialog_format', 'Định dạng tệp'],
['core_blocks_export_image_dialog_transparent_background', 'Nền trong suốt'],
];
@@ -13,4 +13,7 @@ export default [
['core_blocks_exception_message_error_title', '无法显示内容'],
['core_blocks_exception_message_error_message', '出现错误.'],
['core_blocks_custom_input_validation_error', '字段值无效'],
['core_blocks_export_image_dialog_title', '导出图像'],
['core_blocks_export_image_dialog_format', '文件格式'],
['core_blocks_export_image_dialog_transparent_background', '透明背景'],
];
@@ -12,7 +12,7 @@ module.exports = [
'joint',
'backbone',
'lodash',
'canvg',
'modern-screenshot',
'dagre',
'svg-pathdata',
'@emotion',
+1
View File
@@ -27,6 +27,7 @@
"fast-deep-equal": "^3",
"md5": "^2",
"mobx": "^6",
"modern-screenshot": "^4",
"tslib": "^2",
"underscore": "^1",
"uuid": "^13",
@@ -0,0 +1,41 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import * as modernScreenshot from 'modern-screenshot';
import type { Options } from 'modern-screenshot';
import { download } from './download.js';
import { isImageBroken } from './isBrokenImage.js';
export type { Options as IScreenshotOptions };
const BROKEN_IMAGE_ERROR_MESSAGE = 'Something went wrong. please try select another file format';
export async function downloadSvg<T extends Node>(element: T, options: Options, fileName: string): Promise<void> {
const svg = await modernScreenshot.domToForeignObjectSvg<T>(element, options);
const svgData = new XMLSerializer().serializeToString(svg);
const blob = new Blob(['<?xml version="1.0" standalone="no"?>\r\n', svgData], { type: 'image/svg+xml;charset=utf-8' });
const uri = URL.createObjectURL(blob);
const isBrokenImage = await isImageBroken(uri);
if (isBrokenImage) {
throw new Error(BROKEN_IMAGE_ERROR_MESSAGE);
}
download(blob, `${fileName}.svg`);
}
export async function downloadPng<T extends Node>(element: T, options: Options, fileName: string): Promise<void> {
const png = await modernScreenshot.domToPng(element, options);
const isBrokenImage = await isImageBroken(png);
if (isBrokenImage) {
throw new Error(BROKEN_IMAGE_ERROR_MESSAGE);
}
download(png, `${fileName}.png`);
}
+1
View File
@@ -89,3 +89,4 @@ export * from './bindFunctions.js';
export * from './getDomainFromUrl.js';
export * from './isNumber.js';
export * from './getSubjectDifferences.js';
export * from './downloadImage.js';
@@ -0,0 +1,23 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
export function isImageBroken(imageUrl: string): Promise<boolean> {
return new Promise(resolve => {
const img = new Image();
img.onload = () => {
resolve(false);
};
img.onerror = () => {
resolve(true);
};
img.src = imageUrl;
});
}
+8
View File
@@ -2230,6 +2230,7 @@ __metadata:
fast-deep-equal: "npm:^3"
md5: "npm:^2"
mobx: "npm:^6"
modern-screenshot: "npm:^4"
rimraf: "npm:^6"
tslib: "npm:^2"
typescript: "npm:^5"
@@ -15160,6 +15161,13 @@ __metadata:
languageName: node
linkType: hard
"modern-screenshot@npm:^4":
version: 4.6.6
resolution: "modern-screenshot@npm:4.6.6"
checksum: 10c0/0632cdd89db1a7c0b2d8e78d3e05daca77a62d14ee172f6480da3b2de97fd37938c6ed71041271014a601fe2bcb470d01a78c10456ef7ac59a8674b82c64d0a1
languageName: node
linkType: hard
"ms@npm:^2.1.3":
version: 2.1.3
resolution: "ms@npm:2.1.3"