diff --git a/webapp/common-typescript/@dbeaver/js-helpers/package.json b/webapp/common-typescript/@dbeaver/js-helpers/package.json index 7ff67531374..07c0777fb18 100644 --- a/webapp/common-typescript/@dbeaver/js-helpers/package.json +++ b/webapp/common-typescript/@dbeaver/js-helpers/package.json @@ -34,6 +34,7 @@ "dependencies": { "async-mutex": "^0", "color": "^5.0.2", + "modern-screenshot": "^4", "p-debounce": "^5", "p-memoize": "^8.0.0", "quick-lru": "^7.1.0", diff --git a/webapp/common-typescript/@dbeaver/js-helpers/src/IImageExportOptions.ts b/webapp/common-typescript/@dbeaver/js-helpers/src/IImageExportOptions.ts new file mode 100644 index 00000000000..c0b502a4168 --- /dev/null +++ b/webapp/common-typescript/@dbeaver/js-helpers/src/IImageExportOptions.ts @@ -0,0 +1,37 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 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 const IMAGE_EXPORT_FORMATS = ['SVG', 'PNG'] as const; + +export type ImageExportFormat = (typeof IMAGE_EXPORT_FORMATS)[number]; + +export interface IImageExportOptions { + fileName: string; + format: ImageExportFormat; + transparent: boolean; +} + +/** Turns the requested options into the name the file is saved under, see {@link timestampedImageFileName}. */ +export type ImageExportFileNameFormatter = (options: IImageExportOptions) => string; + +export interface IImageExportResult { + fileName: string; + format: ImageExportFormat; + blob: Blob; +} + +export type ImageExportReadyHandler = (result: IImageExportResult) => void | Promise; + +/** For consumers that cannot take a Blob, see {@link toDataUrlImageExport}. */ +export interface IImageExportDataUrlResult { + fileName: string; + format: ImageExportFormat; + dataUrl: string; +} + +export type ImageExportDataUrlReadyHandler = (result: IImageExportDataUrlResult) => void; diff --git a/webapp/common-typescript/@dbeaver/js-helpers/src/blobToDataUrl.ts b/webapp/common-typescript/@dbeaver/js-helpers/src/blobToDataUrl.ts new file mode 100644 index 00000000000..d93fbe0ff27 --- /dev/null +++ b/webapp/common-typescript/@dbeaver/js-helpers/src/blobToDataUrl.ts @@ -0,0 +1,16 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 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 blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); +} diff --git a/webapp/packages/core-utils/src/download.ts b/webapp/common-typescript/@dbeaver/js-helpers/src/download.ts similarity index 94% rename from webapp/packages/core-utils/src/download.ts rename to webapp/common-typescript/@dbeaver/js-helpers/src/download.ts index 31516cbf1fc..c86979801c7 100644 --- a/webapp/packages/core-utils/src/download.ts +++ b/webapp/common-typescript/@dbeaver/js-helpers/src/download.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. diff --git a/webapp/common-typescript/@dbeaver/js-helpers/src/downloadImageExport.ts b/webapp/common-typescript/@dbeaver/js-helpers/src/downloadImageExport.ts new file mode 100644 index 00000000000..23298666a69 --- /dev/null +++ b/webapp/common-typescript/@dbeaver/js-helpers/src/downloadImageExport.ts @@ -0,0 +1,13 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 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 { download } from './download.js'; +import type { IImageExportResult } from './IImageExportOptions.js'; + +export function downloadImageExport(result: IImageExportResult): void { + download(result.blob, result.fileName); +} diff --git a/webapp/common-typescript/@dbeaver/js-helpers/src/exportDomImage.ts b/webapp/common-typescript/@dbeaver/js-helpers/src/exportDomImage.ts new file mode 100644 index 00000000000..993f1b74624 --- /dev/null +++ b/webapp/common-typescript/@dbeaver/js-helpers/src/exportDomImage.ts @@ -0,0 +1,91 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 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 { domToBlob, domToForeignObjectSvg, type Options } from 'modern-screenshot'; + +import { DEFAULT_MAX_IMAGE_SIDE, fitToPixelBudget, type IPixelBudget } from './fitToPixelBudget.js'; +import type { IImageExportOptions, ImageExportFileNameFormatter, ImageExportReadyHandler } from './IImageExportOptions.js'; +import { isImageBroken } from './isImageBroken.js'; +import { timestampedImageFileName } from './timestampedImageFileName.js'; + +export const IMAGE_EXPORT_BROKEN_ERROR = 'Something went wrong. Please try to select another file format'; + +const DEFAULT_BACKGROUND_COLOR = '#ffffff'; +const DEFAULT_PNG_SCALE = 2; + +export interface IDomImageExportConfig extends IPixelBudget { + /** In CSS pixels, defaults to the bounding box of `node`. Required for nodes with no box of their own. */ + width?: number; + height?: number; + /** Used when `options.transparent` is false. */ + backgroundColor?: string; + /** Applied to the clone before rendering, the live DOM is left alone. */ + style?: Partial; + /** Return false to keep a node (and its children) out of the snapshot. */ + filter?: (node: Node) => boolean; + /** Defaults to {@link timestampedImageFileName}. */ + formatFileName?: ImageExportFileNameFormatter; +} + +/** + * PNG is scaled down to fit {@link fitToPixelBudget}, SVG is always 1:1 + */ +export async function exportDomImage( + node: Element, + options: IImageExportOptions, + onReady: ImageExportReadyHandler, + config?: IDomImageExportConfig, +): Promise { + const { width, height } = resolveSize(node, config); + const scale = options.format === 'PNG' ? fitToPixelBudget(width, height, { ...config, scale: config?.scale ?? DEFAULT_PNG_SCALE }) : 1; + + const screenshotOptions: Options = { + // a size given here is forced onto the clone, so pass it on only when asked for + ...(config?.width === undefined ? {} : { width: config.width }), + ...(config?.height === undefined ? {} : { height: config.height }), + scale, + style: config?.style, + filter: config?.filter, + backgroundColor: options.transparent ? 'transparent' : (config?.backgroundColor ?? DEFAULT_BACKGROUND_COLOR), + quality: 1, + maximumCanvasSize: config?.maxSide ?? DEFAULT_MAX_IMAGE_SIDE, + }; + + const blob = options.format === 'PNG' ? await renderPng(node, screenshotOptions) : await renderSvg(node, screenshotOptions); + const uri = URL.createObjectURL(blob); + const broken = await isImageBroken(uri); + URL.revokeObjectURL(uri); + + if (broken) { + throw new Error(IMAGE_EXPORT_BROKEN_ERROR); + } + + const formatFileName = config?.formatFileName ?? timestampedImageFileName; + + await onReady({ fileName: formatFileName(options), format: options.format, blob }); +} + +function renderPng(node: Element, screenshotOptions: Options): Promise { + return domToBlob(node, { ...screenshotOptions, type: 'image/png' }); +} + +async function renderSvg(node: Element, screenshotOptions: Options): Promise { + const svg = await domToForeignObjectSvg(node, screenshotOptions); + const svgData = new XMLSerializer().serializeToString(svg); + + return new Blob(['\r\n', svgData], { type: 'image/svg+xml;charset=utf-8' }); +} + +function resolveSize(node: Element, config?: IDomImageExportConfig): { width: number; height: number } { + if (config?.width !== undefined && config.height !== undefined) { + return { width: config.width, height: config.height }; + } + + const rect = node.getBoundingClientRect(); + + return { width: rect.width, height: rect.height }; +} diff --git a/webapp/common-typescript/@dbeaver/js-helpers/src/fitToPixelBudget.test.ts b/webapp/common-typescript/@dbeaver/js-helpers/src/fitToPixelBudget.test.ts new file mode 100644 index 00000000000..eb33fc16c7c --- /dev/null +++ b/webapp/common-typescript/@dbeaver/js-helpers/src/fitToPixelBudget.test.ts @@ -0,0 +1,42 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 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 { describe, expect, test } from 'vitest'; + +import { DEFAULT_MAX_IMAGE_PIXELS, DEFAULT_MAX_IMAGE_SIDE, fitToPixelBudget } from './fitToPixelBudget.js'; + +describe('fitToPixelBudget', () => { + test('should keep the requested scale when the content fits', () => { + expect(fitToPixelBudget(1200, 800, { scale: 2 })).toBe(2); + }); + + test('should default to scale 1', () => { + expect(fitToPixelBudget(1200, 800)).toBe(1); + }); + + test('should scale down when the total area exceeds the pixel budget', () => { + const scale = fitToPixelBudget(12000, 9000, { scale: 2 }); + + expect(scale).toBeLessThan(1); + expect(12000 * scale * 9000 * scale).toBeLessThanOrEqual(DEFAULT_MAX_IMAGE_PIXELS + 1); + }); + + test('should scale down when a single side exceeds the max side', () => { + const scale = fitToPixelBudget(20000, 300, { scale: 1 }); + + expect(20000 * scale).toBeLessThanOrEqual(DEFAULT_MAX_IMAGE_SIDE + 1); + }); + + test('should respect custom budgets', () => { + expect(fitToPixelBudget(2000, 2000, { scale: 1, maxSide: 1000 })).toBe(0.5); + expect(fitToPixelBudget(2000, 2000, { scale: 1, maxPixels: 1_000_000 })).toBe(0.5); + }); + + test('should not collapse to zero on an enormous content', () => { + expect(fitToPixelBudget(10_000_000, 10_000_000, { scale: 1 })).toBeGreaterThan(0); + }); +}); diff --git a/webapp/common-typescript/@dbeaver/js-helpers/src/fitToPixelBudget.ts b/webapp/common-typescript/@dbeaver/js-helpers/src/fitToPixelBudget.ts new file mode 100644 index 00000000000..9722eb27094 --- /dev/null +++ b/webapp/common-typescript/@dbeaver/js-helpers/src/fitToPixelBudget.ts @@ -0,0 +1,34 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +/** Canvases stop rendering reliably somewhere around this side length. */ +export const DEFAULT_MAX_IMAGE_SIDE = 8192; + +/** ~16 Mpx, about 64 MB of RGBA. */ +export const DEFAULT_MAX_IMAGE_PIXELS = 16_777_216; + +const MIN_SCALE = 0.05; + +export interface IPixelBudget { + /** Device pixel ratio to aim for. Defaults to 1. */ + scale?: number; + maxSide?: number; + maxPixels?: number; +} + +export function fitToPixelBudget(width: number, height: number, budget?: IPixelBudget): number { + const { scale = 1, maxSide = DEFAULT_MAX_IMAGE_SIDE, maxPixels = DEFAULT_MAX_IMAGE_PIXELS } = budget ?? {}; + + if (width <= 0 || height <= 0) { + return scale; + } + + const fitted = Math.min(scale, maxSide / width, maxSide / height, Math.sqrt(maxPixels / (width * height))); + + return Math.max(fitted, Math.min(scale, MIN_SCALE)); +} diff --git a/webapp/common-typescript/@dbeaver/js-helpers/src/index.ts b/webapp/common-typescript/@dbeaver/js-helpers/src/index.ts index ba96d752a7f..fc570bf4c91 100644 --- a/webapp/common-typescript/@dbeaver/js-helpers/src/index.ts +++ b/webapp/common-typescript/@dbeaver/js-helpers/src/index.ts @@ -6,8 +6,18 @@ * you may not use this file except in compliance with the License. */ +export * from './blobToDataUrl.js'; export * from './ColorConvert.js'; export * from './debouncePromise.js'; +export * from './download.js'; +export * from './downloadImageExport.js'; +export * from './exportDomImage.js'; +export * from './fitToPixelBudget.js'; +export * from './IImageExportOptions.js'; +export * from './isImageBroken.js'; +export * from './timestampedImageFileName.js'; +export * from './toDataUrlImageExport.js'; +export * from './withTimestamp.js'; export * from './eventContext.js'; export * from './eventStopPropagationFlag.js'; export * from './isDefined.js'; diff --git a/webapp/packages/core-utils/src/isBrokenImage.ts b/webapp/common-typescript/@dbeaver/js-helpers/src/isImageBroken.ts similarity index 89% rename from webapp/packages/core-utils/src/isBrokenImage.ts rename to webapp/common-typescript/@dbeaver/js-helpers/src/isImageBroken.ts index 28c6f4e70cb..049f2ceb541 100644 --- a/webapp/packages/core-utils/src/isBrokenImage.ts +++ b/webapp/common-typescript/@dbeaver/js-helpers/src/isImageBroken.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. diff --git a/webapp/common-typescript/@dbeaver/js-helpers/src/timestampedImageFileName.test.ts b/webapp/common-typescript/@dbeaver/js-helpers/src/timestampedImageFileName.test.ts new file mode 100644 index 00000000000..4d4c3264e36 --- /dev/null +++ b/webapp/common-typescript/@dbeaver/js-helpers/src/timestampedImageFileName.test.ts @@ -0,0 +1,36 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 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 { describe, expect, test } from 'vitest'; + +import { timestampedImageFileName } from './timestampedImageFileName.js'; + +describe('timestampedImageFileName', () => { + test('should append the timestamp and the extension', () => { + const name = timestampedImageFileName({ fileName: 'Chart', format: 'PNG', transparent: false }); + + expect(name).toMatch(/^Chart \d{4}-\d{2}-\d{2} \d{2}-\d{2}-\d{2}\.png$/); + }); + + test('should lowercase the extension per format', () => { + const name = timestampedImageFileName({ fileName: 'Plan', format: 'SVG', transparent: false }); + + expect(name.endsWith('.svg')).toBe(true); + }); + + test('should trim the requested name', () => { + const name = timestampedImageFileName({ fileName: ' Chart ', format: 'PNG', transparent: false }); + + expect(name.startsWith('Chart 20')).toBe(true); + }); + + test('should fall back when the requested name is blank', () => { + const name = timestampedImageFileName({ fileName: '', format: 'PNG', transparent: false }); + + expect(name.startsWith('image')).toBe(true); + }); +}); diff --git a/webapp/common-typescript/@dbeaver/js-helpers/src/timestampedImageFileName.ts b/webapp/common-typescript/@dbeaver/js-helpers/src/timestampedImageFileName.ts new file mode 100644 index 00000000000..d8c5adaeb65 --- /dev/null +++ b/webapp/common-typescript/@dbeaver/js-helpers/src/timestampedImageFileName.ts @@ -0,0 +1,16 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 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 type { IImageExportOptions } from './IImageExportOptions.js'; +import { withTimestamp } from './withTimestamp.js'; + +const FALLBACK_FILE_NAME = 'image'; + +/** `{ fileName: 'Chart', format: 'PNG' }` → `'Chart 2026-07-27 10-30-00.png'` */ +export function timestampedImageFileName(options: IImageExportOptions): string { + return `${withTimestamp(options.fileName.trim() || FALLBACK_FILE_NAME)}.${options.format.toLowerCase()}`; +} diff --git a/webapp/common-typescript/@dbeaver/js-helpers/src/toDataUrlImageExport.ts b/webapp/common-typescript/@dbeaver/js-helpers/src/toDataUrlImageExport.ts new file mode 100644 index 00000000000..9013e6d094e --- /dev/null +++ b/webapp/common-typescript/@dbeaver/js-helpers/src/toDataUrlImageExport.ts @@ -0,0 +1,15 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 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 { blobToDataUrl } from './blobToDataUrl.js'; +import type { ImageExportDataUrlReadyHandler, ImageExportReadyHandler } from './IImageExportOptions.js'; + +export function toDataUrlImageExport(handler: ImageExportDataUrlReadyHandler): ImageExportReadyHandler { + return async result => { + handler({ fileName: result.fileName, format: result.format, dataUrl: await blobToDataUrl(result.blob) }); + }; +} diff --git a/webapp/packages/core-utils/src/withTimestamp.ts b/webapp/common-typescript/@dbeaver/js-helpers/src/withTimestamp.ts similarity index 77% rename from webapp/packages/core-utils/src/withTimestamp.ts rename to webapp/common-typescript/@dbeaver/js-helpers/src/withTimestamp.ts index 4858da1e1e4..678372b819f 100644 --- a/webapp/packages/core-utils/src/withTimestamp.ts +++ b/webapp/common-typescript/@dbeaver/js-helpers/src/withTimestamp.ts @@ -1,12 +1,12 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2026 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 withTimestamp(value: string) { +export function withTimestamp(value: string): string { const now = new Date(); return `${value} ${now.toISOString().slice(0, 10)} ${('0' + now.getHours()).slice(-2)}-${('0' + now.getMinutes()).slice(-2)}-${( '0' + now.getSeconds() diff --git a/webapp/common-typescript/@dbeaver/js-helpers/tsconfig.json b/webapp/common-typescript/@dbeaver/js-helpers/tsconfig.json index 569071c38b3..1bcc9398e0a 100644 --- a/webapp/common-typescript/@dbeaver/js-helpers/tsconfig.json +++ b/webapp/common-typescript/@dbeaver/js-helpers/tsconfig.json @@ -1,6 +1,11 @@ { "extends": "@dbeaver/tsconfig/tsconfig.json", "compilerOptions": { + "lib": [ + "ESNext", + "DOM", + "DOM.Iterable" + ], "tsBuildInfoFile": "./lib/.tsbuildinfo", "rootDir": "src", "rootDirs": [ diff --git a/webapp/packages/core-blocks/src/ExportImageDialog/ExportImageDialog.tsx b/webapp/packages/core-blocks/src/ExportImageDialog/ExportImageDialog.tsx index 1cd14b82433..5f63ad043d7 100644 --- a/webapp/packages/core-blocks/src/ExportImageDialog/ExportImageDialog.tsx +++ b/webapp/packages/core-blocks/src/ExportImageDialog/ExportImageDialog.tsx @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -92,7 +92,7 @@ export const ExportImageDialog = observer props.rejectDialog()}> {translate('app_shared_inlineEditor_dialog_cancel')} - diff --git a/webapp/packages/core-utils/src/download.test.ts b/webapp/packages/core-utils/src/download.test.ts deleted file mode 100644 index e39e133698a..00000000000 --- a/webapp/packages/core-utils/src/download.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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 { beforeEach, describe, expect, it, vitest } from 'vitest'; - -import { download } from './download.js'; - -describe('download', () => { - beforeEach(() => { - // Clean up any existing links from previous tests - document.body.innerHTML = ''; - }); - - it('should not create links after download', () => { - const blob = new Blob(['test'], { type: 'text/plain' }); - const linksBefore = document.querySelectorAll('a'); - expect(linksBefore.length).toBe(0); - - download(blob, 'test.txt'); - - const linksAfter = document.querySelectorAll('a'); - expect(linksAfter.length).toBe(0); - }); - - it('should create a link with url and click it to download file', () => { - const element = document.createElement('a'); - const createElementSpy = vitest.spyOn(document, 'createElement').mockImplementation(() => element); - const clickSpy = vitest.spyOn(element, 'click'); - const createObjectURLSpy = vitest.spyOn(URL, 'createObjectURL'); - const blob = new Blob(['test'], { type: 'text/plain' }); - - download(blob, 'test.txt'); - - expect(createElementSpy).toHaveBeenCalledWith('a'); - expect(createElementSpy).toHaveBeenCalledTimes(1); - - expect(clickSpy).toHaveBeenCalledWith(); - expect(clickSpy).toHaveBeenCalledTimes(1); - - expect(createObjectURLSpy).toHaveBeenCalledWith(blob); - expect(createObjectURLSpy).toHaveBeenCalledTimes(1); - - createElementSpy.mockRestore(); - clickSpy.mockRestore(); - createObjectURLSpy.mockRestore(); - }); -}); diff --git a/webapp/packages/core-utils/src/downloadImage.ts b/webapp/packages/core-utils/src/downloadImage.ts index a8d007b7dd7..323688e3e45 100644 --- a/webapp/packages/core-utils/src/downloadImage.ts +++ b/webapp/packages/core-utils/src/downloadImage.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -8,8 +8,7 @@ import * as modernScreenshot from 'modern-screenshot'; import type { Options } from 'modern-screenshot'; -import { download } from './download.js'; -import { isImageBroken } from './isBrokenImage.js'; +import { download, isImageBroken } from '@dbeaver/js-helpers'; export type { Options as IScreenshotOptions }; diff --git a/webapp/packages/core-utils/src/index.ts b/webapp/packages/core-utils/src/index.ts index cfdd6140fac..04172e3524c 100644 --- a/webapp/packages/core-utils/src/index.ts +++ b/webapp/packages/core-utils/src/index.ts @@ -62,7 +62,6 @@ export * from './getUniqueName.js'; export * from './isMapsEqual.js'; export * from './isObjectsEqual.js'; export * from './openCenteredPopup.js'; -export * from './download.js'; export * from './downloadFromURL.js'; export * from './getTextFileReadingProcess.js'; export * from './getTextBetween.js'; @@ -81,7 +80,6 @@ export * from './removeMetadataFromDataURL.js'; export * from './removeLineBreak.js'; export * from './replaceSubstring.js'; export * from './formatNumber.js'; -export * from './withTimestamp.js'; export * from './toSafeHtmlString.js'; export * from './getProgressPercent.js'; export * from './types/UndefinedToNull.js'; diff --git a/webapp/packages/core-utils/src/isBrokenImage.test.ts b/webapp/packages/core-utils/src/isBrokenImage.test.ts deleted file mode 100644 index 68772965ab6..00000000000 --- a/webapp/packages/core-utils/src/isBrokenImage.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { isImageBroken } from './isBrokenImage.js'; - -describe('isImageBroken', () => { - const OriginalImage = globalThis.Image; - - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - if (OriginalImage) { - globalThis.Image = OriginalImage; - } else { - delete (globalThis as Record)['Image']; - } - vi.restoreAllMocks(); - vi.useRealTimers(); - }); - - it('should return false if image loads successfully', async () => { - const srcSpy = vi.fn(); - - class MockImage { - public onload: (() => void) | null = null; - public onerror: (() => void) | null = null; - - set src(value: string) { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const thisRef = this; - srcSpy(value); - setTimeout(() => { - thisRef.onload?.(); - }, 0); - vi.runAllTimers(); - } - } - - vi.stubGlobal('Image', MockImage); - - await expect(isImageBroken('https://example.com/ok.png')).resolves.toBe(false); - expect(srcSpy).toHaveBeenCalledWith('https://example.com/ok.png'); - }); - - it('should return true if image loading fails', async () => { - const srcSpy = vi.fn(); - - class MockImage { - public onload: (() => void) | null = null; - public onerror: (() => void) | null = null; - - set src(value: string) { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const thisRef = this; - srcSpy(value); - setTimeout(() => { - thisRef.onerror?.(); - }, 0); - vi.runAllTimers(); - } - } - - vi.stubGlobal('Image', MockImage); - - await expect(isImageBroken('https://example.com/broken.png')).resolves.toBe(true); - expect(srcSpy).toHaveBeenCalledWith('https://example.com/broken.png'); - }); -}); diff --git a/webapp/packages/core-utils/src/withTimestamp.test.ts b/webapp/packages/core-utils/src/withTimestamp.test.ts deleted file mode 100644 index 01acc095e97..00000000000 --- a/webapp/packages/core-utils/src/withTimestamp.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { withTimestamp } from './withTimestamp.js'; - -describe('withTimestamp', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('should generate a value with timestamp at the end', () => { - const mockDate = new Date('2020-09-09T14:13:20'); - vi.setSystemTime(mockDate); - - const value = 'value'; - const expectedValue = `${value} 2020-09-09 14-13-20`; - - expect(withTimestamp(value)).toEqual(expectedValue); - }); -}); diff --git a/webapp/packages/core-view/public/icons/export_as_img.svg b/webapp/packages/core-view/public/icons/export_as_img.svg new file mode 100644 index 00000000000..17a5f6972a1 --- /dev/null +++ b/webapp/packages/core-view/public/icons/export_as_img.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/webapp/packages/core-view/public/icons/export_as_img_m.svg b/webapp/packages/core-view/public/icons/export_as_img_m.svg new file mode 100644 index 00000000000..7788f156877 --- /dev/null +++ b/webapp/packages/core-view/public/icons/export_as_img_m.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/webapp/packages/core-view/public/icons/export_as_img_sm.svg b/webapp/packages/core-view/public/icons/export_as_img_sm.svg new file mode 100644 index 00000000000..f1083f26057 --- /dev/null +++ b/webapp/packages/core-view/public/icons/export_as_img_sm.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-data-export/package.json b/webapp/packages/plugin-data-export/package.json index 25d791dab55..66b56ca8257 100644 --- a/webapp/packages/plugin-data-export/package.json +++ b/webapp/packages/plugin-data-export/package.json @@ -38,6 +38,7 @@ "@cloudbeaver/plugin-data-viewer": "workspace:*", "@cloudbeaver/plugin-navigation-tree": "workspace:*", "@cloudbeaver/plugin-sql-editor": "workspace:*", + "@dbeaver/js-helpers": "workspace:*", "mobx": "^6", "mobx-react-lite": "^4", "react": "^19", diff --git a/webapp/packages/plugin-data-export/src/DataExportMenuService.ts b/webapp/packages/plugin-data-export/src/DataExportMenuService.ts index d04c6abddd6..2df3e425461 100644 --- a/webapp/packages/plugin-data-export/src/DataExportMenuService.ts +++ b/webapp/packages/plugin-data-export/src/DataExportMenuService.ts @@ -11,7 +11,7 @@ import { injectable } from '@cloudbeaver/core-di'; import { CommonDialogService } from '@cloudbeaver/core-dialogs'; import { LocalizationService } from '@cloudbeaver/core-localization'; import { DATA_CONTEXT_NAV_NODE, EObjectFeature } from '@cloudbeaver/core-navigation-tree'; -import { withTimestamp } from '@cloudbeaver/core-utils'; +import { withTimestamp } from '@dbeaver/js-helpers'; import { ACTION_EXPORT, ActionService, menuExtractItems, MenuService } from '@cloudbeaver/core-view'; import { DATA_CONTEXT_DV_DDM, diff --git a/webapp/packages/plugin-data-export/tsconfig.json b/webapp/packages/plugin-data-export/tsconfig.json index b7ff6da3321..5d34c6f85b5 100644 --- a/webapp/packages/plugin-data-export/tsconfig.json +++ b/webapp/packages/plugin-data-export/tsconfig.json @@ -10,6 +10,9 @@ { "path": "../../common-typescript/@dbeaver/cli" }, + { + "path": "../../common-typescript/@dbeaver/js-helpers" + }, { "path": "../core-blocks" }, diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction.ts index 203e1da56a6..81ebfb1844c 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -10,8 +10,8 @@ import { makeObservable, observable } from 'mobx'; import { QuotasService, ServerResourceQuotasResource } from '@cloudbeaver/core-root'; import { GraphQLService, ResultDataFormat } from '@cloudbeaver/core-sdk'; import { isResultSetContentValue } from '@dbeaver/result-set-api'; -import { bytesToSize, download, downloadFromURL, GlobalConstants } from '@cloudbeaver/core-utils'; -import { isNotNullDefined } from '@dbeaver/js-helpers'; +import { bytesToSize, downloadFromURL, GlobalConstants } from '@cloudbeaver/core-utils'; +import { download, isNotNullDefined } from '@dbeaver/js-helpers'; import { DatabaseDataAction } from '../../DatabaseDataAction.js'; import { IDatabaseDataSource } from '../../IDatabaseDataSource.js'; diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/useValuePanelImageValue.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/useValuePanelImageValue.ts index a91ba4b18fb..ce2158caf5f 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/useValuePanelImageValue.ts +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/useValuePanelImageValue.ts @@ -12,7 +12,8 @@ import { promptForFiles } from '@cloudbeaver/core-browser'; import { ConnectionInfoResource, createConnectionParam } from '@cloudbeaver/core-connections'; import { useService } from '@cloudbeaver/core-di'; import { NotificationService } from '@cloudbeaver/core-events'; -import { download, getMIME, isImageFormat, isValidUrl } from '@cloudbeaver/core-utils'; +import { getMIME, isImageFormat, isValidUrl } from '@cloudbeaver/core-utils'; +import { download } from '@dbeaver/js-helpers'; import { isResultSetBinaryValue } from '@dbeaver/result-set-api'; import { createResultSetBlobValue } from '../../DatabaseDataModel/Actions/ResultSet/createResultSetBlobValue.js'; diff --git a/webapp/packages/plugin-ddl-viewer/package.json b/webapp/packages/plugin-ddl-viewer/package.json index 8f882961a45..58aa5d1277a 100644 --- a/webapp/packages/plugin-ddl-viewer/package.json +++ b/webapp/packages/plugin-ddl-viewer/package.json @@ -36,6 +36,7 @@ "@cloudbeaver/plugin-sql-editor": "workspace:*", "@cloudbeaver/plugin-sql-editor-codemirror": "workspace:*", "@cloudbeaver/plugin-sql-editor-navigation-tab": "workspace:*", + "@dbeaver/js-helpers": "workspace:*", "mobx": "^6", "mobx-react-lite": "^4", "react": "^19", diff --git a/webapp/packages/plugin-ddl-viewer/src/DdlViewer/DDLViewerFooterService.ts b/webapp/packages/plugin-ddl-viewer/src/DdlViewer/DDLViewerFooterService.ts index 8e4ead0170b..30a2a12f42a 100644 --- a/webapp/packages/plugin-ddl-viewer/src/DdlViewer/DDLViewerFooterService.ts +++ b/webapp/packages/plugin-ddl-viewer/src/DdlViewer/DDLViewerFooterService.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -8,7 +8,7 @@ import { ConnectionInfoResource, createConnectionParam } from '@cloudbeaver/core-connections'; import { injectable } from '@cloudbeaver/core-di'; import { NavNodeManagerService } from '@cloudbeaver/core-navigation-tree'; -import { download, withTimestamp } from '@cloudbeaver/core-utils'; +import { download, withTimestamp } from '@dbeaver/js-helpers'; import { ACTION_SAVE, ActionService, MenuService } from '@cloudbeaver/core-view'; import { LocalStorageSqlDataSource } from '@cloudbeaver/plugin-sql-editor'; import { ACTION_SQL_EDITOR_OPEN, SqlEditorNavigatorService } from '@cloudbeaver/plugin-sql-editor-navigation-tab'; diff --git a/webapp/packages/plugin-ddl-viewer/tsconfig.json b/webapp/packages/plugin-ddl-viewer/tsconfig.json index 000492456e9..cbfa4f5193d 100644 --- a/webapp/packages/plugin-ddl-viewer/tsconfig.json +++ b/webapp/packages/plugin-ddl-viewer/tsconfig.json @@ -7,6 +7,9 @@ "composite": true }, "references": [ + { + "path": "../../common-typescript/@dbeaver/js-helpers" + }, { "path": "../core-blocks" }, diff --git a/webapp/packages/plugin-sql-editor/src/LocalExport/LocalExportPanel.tsx b/webapp/packages/plugin-sql-editor/src/LocalExport/LocalExportPanel.tsx index 7fac98e8fbd..21c13f76167 100644 --- a/webapp/packages/plugin-sql-editor/src/LocalExport/LocalExportPanel.tsx +++ b/webapp/packages/plugin-sql-editor/src/LocalExport/LocalExportPanel.tsx @@ -11,7 +11,7 @@ import { InputField, Translate, useForm } from '@cloudbeaver/core-blocks'; import { ExportScriptDialogContext, type IScriptExportTabProps } from '@cloudbeaver/plugin-script-export'; import type { TabContainerPanelComponent } from '@cloudbeaver/core-ui'; import { downloadSql } from '../downloadSql.js'; -import { withTimestamp } from '@cloudbeaver/core-utils'; +import { withTimestamp } from '@dbeaver/js-helpers'; import { useState, useContext } from 'react'; export const LocalExportPanel: TabContainerPanelComponent = observer(function LocalExportPanel({ diff --git a/webapp/packages/plugin-sql-editor/src/MenuBootstrap.ts b/webapp/packages/plugin-sql-editor/src/MenuBootstrap.ts index beaebae84d4..d83db74f68d 100644 --- a/webapp/packages/plugin-sql-editor/src/MenuBootstrap.ts +++ b/webapp/packages/plugin-sql-editor/src/MenuBootstrap.ts @@ -8,7 +8,8 @@ import type { IDataContextProvider } from '@cloudbeaver/core-data-context'; import { Bootstrap, injectable } from '@cloudbeaver/core-di'; import { WindowEventsService } from '@cloudbeaver/core-root'; -import { getTextFileReadingProcess, throttle, withTimestamp } from '@cloudbeaver/core-utils'; +import { getTextFileReadingProcess, throttle } from '@cloudbeaver/core-utils'; +import { withTimestamp } from '@dbeaver/js-helpers'; import { ACTION_DOWNLOAD, ACTION_REDO, diff --git a/webapp/packages/plugin-sql-editor/src/downloadSql.ts b/webapp/packages/plugin-sql-editor/src/downloadSql.ts index b9ffa82748d..9d5e6c82956 100644 --- a/webapp/packages/plugin-sql-editor/src/downloadSql.ts +++ b/webapp/packages/plugin-sql-editor/src/downloadSql.ts @@ -6,7 +6,7 @@ * you may not use this file except in compliance with the License. */ -import { download } from '@cloudbeaver/core-utils'; +import { download } from '@dbeaver/js-helpers'; function sanitizeFilename(filename: string): string { // Remove or replace invalid filesystem characters diff --git a/webapp/packages/plugin-sql-generator/package.json b/webapp/packages/plugin-sql-generator/package.json index 92bd0f40de3..9d0e2a0127f 100644 --- a/webapp/packages/plugin-sql-generator/package.json +++ b/webapp/packages/plugin-sql-generator/package.json @@ -38,6 +38,7 @@ "@cloudbeaver/plugin-sql-editor": "workspace:*", "@cloudbeaver/plugin-sql-editor-codemirror": "workspace:*", "@cloudbeaver/plugin-sql-editor-navigation-tab": "workspace:*", + "@dbeaver/js-helpers": "workspace:*", "mobx": "^6", "mobx-react-lite": "^4", "react": "^19", diff --git a/webapp/packages/plugin-sql-generator/src/SqlGenerators/GeneratedSqlDialog.tsx b/webapp/packages/plugin-sql-generator/src/SqlGenerators/GeneratedSqlDialog.tsx index e448a1061f8..75faaafa144 100644 --- a/webapp/packages/plugin-sql-generator/src/SqlGenerators/GeneratedSqlDialog.tsx +++ b/webapp/packages/plugin-sql-generator/src/SqlGenerators/GeneratedSqlDialog.tsx @@ -23,7 +23,7 @@ import { import { ConnectionDialectResource, ConnectionInfoResource, createConnectionParam } from '@cloudbeaver/core-connections'; import { useService } from '@cloudbeaver/core-di'; import type { DialogComponentProps } from '@cloudbeaver/core-dialogs'; -import { download, withTimestamp } from '@cloudbeaver/core-utils'; +import { download, withTimestamp } from '@dbeaver/js-helpers'; import { NavNodeManagerService } from '@cloudbeaver/core-navigation-tree'; import { useCodemirrorExtensions } from '@cloudbeaver/plugin-codemirror6'; import { SqlEditorNavigatorService } from '@cloudbeaver/plugin-sql-editor-navigation-tab'; diff --git a/webapp/packages/plugin-sql-generator/tsconfig.json b/webapp/packages/plugin-sql-generator/tsconfig.json index b13d67548af..95eb404164a 100644 --- a/webapp/packages/plugin-sql-generator/tsconfig.json +++ b/webapp/packages/plugin-sql-generator/tsconfig.json @@ -10,6 +10,9 @@ { "path": "../../common-typescript/@dbeaver/cli" }, + { + "path": "../../common-typescript/@dbeaver/js-helpers" + }, { "path": "../core-blocks" }, diff --git a/webapp/yarn.lock b/webapp/yarn.lock index 4ff41a0b259..c0159b38add 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -3110,6 +3110,7 @@ __metadata: "@cloudbeaver/plugin-sql-editor": "workspace:*" "@cloudbeaver/tsconfig": "workspace:*" "@dbeaver/cli": "workspace:*" + "@dbeaver/js-helpers": "workspace:*" "@types/react": "npm:^19" mobx: "npm:^6" mobx-react-lite: "npm:^4" @@ -3515,6 +3516,7 @@ __metadata: "@cloudbeaver/plugin-sql-editor-codemirror": "workspace:*" "@cloudbeaver/plugin-sql-editor-navigation-tab": "workspace:*" "@cloudbeaver/tsconfig": "workspace:*" + "@dbeaver/js-helpers": "workspace:*" "@types/react": "npm:^19" mobx: "npm:^6" mobx-react-lite: "npm:^4" @@ -4727,6 +4729,7 @@ __metadata: "@cloudbeaver/plugin-sql-editor-navigation-tab": "workspace:*" "@cloudbeaver/tsconfig": "workspace:*" "@dbeaver/cli": "workspace:*" + "@dbeaver/js-helpers": "workspace:*" "@types/react": "npm:^19" mobx: "npm:^6" mobx-react-lite: "npm:^4" @@ -5363,6 +5366,7 @@ __metadata: "@dbeaver/tsconfig": "workspace:^" async-mutex: "npm:^0" color: "npm:^5.0.2" + modern-screenshot: "npm:^4" p-debounce: "npm:^5" p-memoize: "npm:^8.0.0" quick-lru: "npm:^7.1.0" @@ -16099,6 +16103,13 @@ __metadata: languageName: node linkType: hard +"modern-screenshot@npm:^4": + version: 4.7.0 + resolution: "modern-screenshot@npm:4.7.0" + checksum: 10c0/9c5fbe4f4c73d3dcf0ba7fb76c1671ad148fdedffc84700e0b8ca1c1640a6ab1930718ae1424b0a6517b98d8f18c898ea0e62ee0ca57a25bfce7b914df721a63 + languageName: node + linkType: hard + "ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3"