From 37cfecbf91b49dae138a79ea2542937633a7b3bc Mon Sep 17 00:00:00 2001 From: sergeyteleshev Date: Mon, 27 Oct 2025 10:09:23 +0100 Subject: [PATCH] [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 b59374cf85c6cfcb2995eaaa444c7a23e37320be. * 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> --- .../ExportImageDialog/ExportImageDialog.tsx | 101 ++++++++++++++++++ .../ExportImageDialogLazy.ts | 11 ++ .../ExportImageDialog/ExportImageFormats.ts | 11 ++ webapp/packages/core-blocks/src/index.ts | 2 + webapp/packages/core-blocks/src/locales/en.ts | 3 + webapp/packages/core-blocks/src/locales/fr.ts | 3 + webapp/packages/core-blocks/src/locales/it.ts | 3 + webapp/packages/core-blocks/src/locales/ru.ts | 3 + webapp/packages/core-blocks/src/locales/vi.ts | 3 + webapp/packages/core-blocks/src/locales/zh.ts | 3 + .../core-cli/configs/excludedFromVendor.js | 2 +- webapp/packages/core-utils/package.json | 1 + .../packages/core-utils/src/downloadImage.ts | 41 +++++++ webapp/packages/core-utils/src/index.ts | 1 + .../packages/core-utils/src/isBrokenImage.ts | 23 ++++ webapp/yarn.lock | 8 ++ 16 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 webapp/packages/core-blocks/src/ExportImageDialog/ExportImageDialog.tsx create mode 100644 webapp/packages/core-blocks/src/ExportImageDialog/ExportImageDialogLazy.ts create mode 100644 webapp/packages/core-blocks/src/ExportImageDialog/ExportImageFormats.ts create mode 100644 webapp/packages/core-utils/src/downloadImage.ts create mode 100644 webapp/packages/core-utils/src/isBrokenImage.ts diff --git a/webapp/packages/core-blocks/src/ExportImageDialog/ExportImageDialog.tsx b/webapp/packages/core-blocks/src/ExportImageDialog/ExportImageDialog.tsx new file mode 100644 index 0000000000..8925c41198 --- /dev/null +++ b/webapp/packages/core-blocks/src/ExportImageDialog/ExportImageDialog.tsx @@ -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; + defaultFileName: string; +} + +interface State extends IExportImageOptions { + error: Error | null; +} + +export const ExportImageDialog = observer>(function ExportImageDialog(props) { + const translate = useTranslate(); + const state = useObservableRef( + () => ({ + 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 ( + + + + + + + {translate('ui_name')} + + + + + + {errorDetails.name && ( + + )} + + + + + + + ); +}); diff --git a/webapp/packages/core-blocks/src/ExportImageDialog/ExportImageDialogLazy.ts b/webapp/packages/core-blocks/src/ExportImageDialog/ExportImageDialogLazy.ts new file mode 100644 index 0000000000..a1eaf79d13 --- /dev/null +++ b/webapp/packages/core-blocks/src/ExportImageDialog/ExportImageDialogLazy.ts @@ -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)); diff --git a/webapp/packages/core-blocks/src/ExportImageDialog/ExportImageFormats.ts b/webapp/packages/core-blocks/src/ExportImageDialog/ExportImageFormats.ts new file mode 100644 index 0000000000..96eeb96b97 --- /dev/null +++ b/webapp/packages/core-blocks/src/ExportImageDialog/ExportImageFormats.ts @@ -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']; diff --git a/webapp/packages/core-blocks/src/index.ts b/webapp/packages/core-blocks/src/index.ts index 084d2902dc..7d77c95ae9 100644 --- a/webapp/packages/core-blocks/src/index.ts +++ b/webapp/packages/core-blocks/src/index.ts @@ -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'; diff --git a/webapp/packages/core-blocks/src/locales/en.ts b/webapp/packages/core-blocks/src/locales/en.ts index eef4e14145..82f7e77857 100644 --- a/webapp/packages/core-blocks/src/locales/en.ts +++ b/webapp/packages/core-blocks/src/locales/en.ts @@ -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'], ]; diff --git a/webapp/packages/core-blocks/src/locales/fr.ts b/webapp/packages/core-blocks/src/locales/fr.ts index c30a968a4a..86ac96a19d 100644 --- a/webapp/packages/core-blocks/src/locales/fr.ts +++ b/webapp/packages/core-blocks/src/locales/fr.ts @@ -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'], ]; diff --git a/webapp/packages/core-blocks/src/locales/it.ts b/webapp/packages/core-blocks/src/locales/it.ts index d30885b9f4..8d867583f8 100644 --- a/webapp/packages/core-blocks/src/locales/it.ts +++ b/webapp/packages/core-blocks/src/locales/it.ts @@ -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'], ]; diff --git a/webapp/packages/core-blocks/src/locales/ru.ts b/webapp/packages/core-blocks/src/locales/ru.ts index 1052f7b2e7..2dfbd769eb 100644 --- a/webapp/packages/core-blocks/src/locales/ru.ts +++ b/webapp/packages/core-blocks/src/locales/ru.ts @@ -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', 'Прозрачный фон'], ]; diff --git a/webapp/packages/core-blocks/src/locales/vi.ts b/webapp/packages/core-blocks/src/locales/vi.ts index 027f3ba89d..432e7636fe 100644 --- a/webapp/packages/core-blocks/src/locales/vi.ts +++ b/webapp/packages/core-blocks/src/locales/vi.ts @@ -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'], ]; diff --git a/webapp/packages/core-blocks/src/locales/zh.ts b/webapp/packages/core-blocks/src/locales/zh.ts index e5afd145c4..db0dc3ad28 100644 --- a/webapp/packages/core-blocks/src/locales/zh.ts +++ b/webapp/packages/core-blocks/src/locales/zh.ts @@ -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', '透明背景'], ]; diff --git a/webapp/packages/core-cli/configs/excludedFromVendor.js b/webapp/packages/core-cli/configs/excludedFromVendor.js index 042c999133..4938811217 100644 --- a/webapp/packages/core-cli/configs/excludedFromVendor.js +++ b/webapp/packages/core-cli/configs/excludedFromVendor.js @@ -12,7 +12,7 @@ module.exports = [ 'joint', 'backbone', 'lodash', - 'canvg', + 'modern-screenshot', 'dagre', 'svg-pathdata', '@emotion', diff --git a/webapp/packages/core-utils/package.json b/webapp/packages/core-utils/package.json index b02a677544..dd7eb775a1 100644 --- a/webapp/packages/core-utils/package.json +++ b/webapp/packages/core-utils/package.json @@ -27,6 +27,7 @@ "fast-deep-equal": "^3", "md5": "^2", "mobx": "^6", + "modern-screenshot": "^4", "tslib": "^2", "underscore": "^1", "uuid": "^13", diff --git a/webapp/packages/core-utils/src/downloadImage.ts b/webapp/packages/core-utils/src/downloadImage.ts new file mode 100644 index 0000000000..2b220cbd79 --- /dev/null +++ b/webapp/packages/core-utils/src/downloadImage.ts @@ -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(element: T, options: Options, fileName: string): Promise { + const svg = await modernScreenshot.domToForeignObjectSvg(element, options); + const svgData = new XMLSerializer().serializeToString(svg); + const blob = new Blob(['\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(element: T, options: Options, fileName: string): Promise { + 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`); +} diff --git a/webapp/packages/core-utils/src/index.ts b/webapp/packages/core-utils/src/index.ts index 952e988d0c..d8f84353ff 100644 --- a/webapp/packages/core-utils/src/index.ts +++ b/webapp/packages/core-utils/src/index.ts @@ -89,3 +89,4 @@ export * from './bindFunctions.js'; export * from './getDomainFromUrl.js'; export * from './isNumber.js'; export * from './getSubjectDifferences.js'; +export * from './downloadImage.js'; diff --git a/webapp/packages/core-utils/src/isBrokenImage.ts b/webapp/packages/core-utils/src/isBrokenImage.ts new file mode 100644 index 0000000000..28c6f4e70c --- /dev/null +++ b/webapp/packages/core-utils/src/isBrokenImage.ts @@ -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 { + return new Promise(resolve => { + const img = new Image(); + + img.onload = () => { + resolve(false); + }; + + img.onerror = () => { + resolve(true); + }; + + img.src = imageUrl; + }); +} diff --git a/webapp/yarn.lock b/webapp/yarn.lock index b34d477ca3..c2a946203a 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -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"