Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions webapp/common-typescript/@dbeaver/js-helpers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>;

/** 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;
16 changes: 16 additions & 0 deletions webapp/common-typescript/@dbeaver/js-helpers/src/blobToDataUrl.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
91 changes: 91 additions & 0 deletions webapp/common-typescript/@dbeaver/js-helpers/src/exportDomImage.ts
Original file line number Diff line number Diff line change
@@ -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<CSSStyleDeclaration>;
/** 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<void> {
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<Blob> {
return domToBlob(node, { ...screenshotOptions, type: 'image/png' });
}

async function renderSvg(node: Element, screenshotOptions: Options): Promise<Blob> {
const svg = await domToForeignObjectSvg(node, screenshotOptions);
const svgData = new XMLSerializer().serializeToString(svg);

return new Blob(['<?xml version="1.0" standalone="no"?>\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 };
}
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
Comment thread
sergeyteleshev marked this conversation as resolved.

const fitted = Math.min(scale, maxSide / width, maxSide / height, Math.sqrt(maxPixels / (width * height)));

return Math.max(fitted, Math.min(scale, MIN_SCALE));
}
10 changes: 10 additions & 0 deletions webapp/common-typescript/@dbeaver/js-helpers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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()}`;
}
Original file line number Diff line number Diff line change
@@ -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) });
};
}
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
sergeyteleshev marked this conversation as resolved.
const now = new Date();
return `${value} ${now.toISOString().slice(0, 10)} ${('0' + now.getHours()).slice(-2)}-${('0' + now.getMinutes()).slice(-2)}-${(
'0' + now.getSeconds()
Expand Down
5 changes: 5 additions & 0 deletions webapp/common-typescript/@dbeaver/js-helpers/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
{
"extends": "@dbeaver/tsconfig/tsconfig.json",
"compilerOptions": {
"lib": [
"ESNext",
"DOM",
"DOM.Iterable"
],
"tsBuildInfoFile": "./lib/.tsbuildinfo",
"rootDir": "src",
"rootDirs": [
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -32,7 +32,7 @@
fileName: string;
}

export interface ExportImagePayload {

Check failure on line 35 in webapp/packages/core-blocks/src/ExportImageDialog/ExportImageDialog.tsx

View workflow job for this annotation

GitHub Actions / Frontend / Lint

Interface name `ExportImagePayload` must match the RegExp: /^I[A-Z]/u
export: (options: IExportImageOptions) => Promise<void>;
defaultFileName: string;
}
Expand Down Expand Up @@ -92,7 +92,7 @@
<Button type="button" variant="secondary" onClick={() => props.rejectDialog()}>
{translate('app_shared_inlineEditor_dialog_cancel')}
</Button>
<Button type="button" onClick={onExportHandler}>
<Button type="button" loader onClick={onExportHandler}>
{translate('ui_export')}
</Button>
</CommonDialogFooter>
Expand Down
Loading
Loading