code:
import {BB} from '../../../bb/bb';
import {FreeTransformCanvas} from '../components/free-transform-canvas';
import {showModal} from './base/showModal';
import {KlCanvas} from '../../canvas/kl-canvas';
import {IKlBasicLayer} from '../../kl-types';
import {LANG} from '../../../language/language';

export function showImportAsLayerDialog (
    params: {
        target: HTMLElement;
        klCanvas: KlCanvas;
        importImage: HTMLImageElement | HTMLCanvasElement;
        callback: (
            p?: {x: number; y: number; width:number; height: number; angleDeg: number},
            isPixelated?: boolean
        ) => void;
    }
): void {

    const div = document.createElement('div');
    BB.appendTextDiv(div, LANG('import-as-layer-description'));
    if (params.klCanvas.isLayerLimitReached()) {
        const noteEl = BB.el({
            content: LANG('import-as-layer-limit-reached'),
            css: {
                background: '#ff0',
                padding: '10px',
                marginTop: '5px',
                marginBottom: '5px',
                border: '1px solid #e7d321',
                borderRadius: '5px',
            },
        });
        div.append(noteEl);
    }
    const isSmall = window.innerWidth < 550;

    const buttonRowEl = BB.el({
        css: {
            display: 'flex',
        },
    });
    const originalSizeBtn = BB.el({
        tagName: 'button',
        content: '1:1',
        css: {
            marginRight: '10px',
        },
        onClick: function () {
            freeTransformCanvas.reset();
        },
    });
    const fitSizeBtn = BB.el({
        tagName: 'button',
        content: LANG('import-as-layer-fit'),
        css: {
            marginRight: '10px',
        },
        onClick: function () {
            freeTransformCanvas.setTransformFit();
        },
    });
    const centerBtn = BB.el({
        tagName: 'button',
        content: LANG('center'),
        css: {
            marginRight: '10px',
        },
        onClick: function () {
            freeTransformCanvas.setTransformCenter();
        },
    });
    buttonRowEl.append(originalSizeBtn, fitSizeBtn, centerBtn);
    div.append(buttonRowEl);

    const layers: IKlBasicLayer[] = [];
    {
        const klCanvasLayerArr = params.klCanvas.getLayers();
        for (let i = 0; i < klCanvasLayerArr.length; i++) {
            layers.push({
                image: klCanvasLayerArr[i].context.canvas,
                isVisible: klCanvasLayerArr[i].isVisible,
                opacity: klCanvasLayerArr[i].opacity,
                mixModeStr: klCanvasLayerArr[i].mixModeStr,
            });
        }
    }
    layers.push({
        image: params.importImage,
        isVisible: true,
        opacity: 1,
        mixModeStr: 'source-over',
    });

    const freeTransformCanvas = new FreeTransformCanvas({
        elementWidth: isSmall ? 340 : 540,
        elementHeight: isSmall ? 280 : 350,
        imageWidth: params.klCanvas.getLayerContext(0)!.canvas.width,
        imageHeight: params.klCanvas.getLayerContext(0)!.canvas.height,
        layers: layers,
        transformIndex: layers.length - 1,
    });
    BB.css(freeTransformCanvas.getElement(), {
        marginTop: '10px',
        marginLeft: '-20px',
    });
    div.append(freeTransformCanvas.getElement());

    function move (x: number, y: number): void {
        freeTransformCanvas.move(x, y);
    }

    const keyListener = new BB.KeyListener({
        onDown: function (keyStr) {
            if (keyStr === 'left') {
                move(-1, 0);
            }
            if (keyStr === 'right') {
                move(1, 0);
            }
            if (keyStr === 'up') {
                move(0, -1);
            }
            if (keyStr === 'down') {
                move(0, 1);
            }
        },
    });

    showModal({
        target: params.target,
        message: `<b>${LANG('import-as-layer-title')}</b>`,
        div: div,
        style: isSmall ? undefined : {
            width: '500px',
        },
        buttons: ['Ok', 'Cancel'],
        clickOnEnter: 'Ok',
        callback: function (buttonStr) {
            keyListener.destroy();
            freeTransformCanvas.destroy();
            BB.destroyEl(originalSizeBtn);
            BB.destroyEl(fitSizeBtn);
            BB.destroyEl(centerBtn);
            if (buttonStr === 'Ok') {
                params.callback(freeTransformCanvas.getTransformation(), freeTransformCanvas.getIsPixelated());
            } else {
                params.callback();
            }
        },
    });

}


code:
import {BB} from '../../../bb/bb';
import {CropCopy} from '../components/crop-copy';
import {Checkbox} from '../components/checkbox';
import {showModal} from './base/showModal';
import {LANG} from '../../../language/language';
import {IKeyString, IRect} from '../../../bb/bb-types';
import {IKlPsd, TKlPsdError} from '../../kl-types';

/**
 * Shows first dialog when importing an image.
 * Where you can crop, and select import as layer or as image.
 */
export function showImportImageDialog (
    p: {
        image: IKlPsd | {
            type: 'image';
            width: number;
            height: number;
            canvas: HTMLImageElement | HTMLCanvasElement;
        };
        maxSize: number;
        target: HTMLElement;
        callback: (
            val: {
                type: 'as-image' | 'as-layer';
                image: HTMLImageElement | HTMLCanvasElement;
            } | {
                type: 'as-image-psd';
                image: IKlPsd;
                cropObj: IRect;
            } | {
                type: 'cancel';
            }
        ) => void;
    }
): void {
    const rootEl = BB.el();
    const isSmall = window.innerWidth < 550 || window.innerHeight < 550;
    const style: IKeyString = isSmall ? {} : { width: '500px' };

    const resolutionEl = BB.el({
        css: {
            marginTop: '10px',
            textAlign: 'center',
            color: '#888',
            lineHeight: '20px',
        },
    });

    const cropCopy = new CropCopy({
        width: isSmall ? 340 : 540,
        height: isSmall ? 300 : 400,
        canvas: p.image.canvas,
        isNotCopy: true,
        onChange: (width, height): void => {
            if (!resolutionEl) {
                return;
            }
            updateResolution(width, height);
        },
    });
    BB.css(cropCopy.getEl(), {
        marginLeft: '-20px',
    });
    cropCopy.getEl().title = LANG('crop-drag-to-crop');

    rootEl.append(cropCopy.getEl(), resolutionEl);

    function updateResolution (w: number, h: number): void {
        const fit = BB.fitInto(w, h, p.maxSize, p.maxSize);

        if (fit.width < w) {
            resolutionEl.innerHTML = `<span class="kl-text-error">${w} X ${h}</span> ⟶ ${Math.round(fit.width)} X ${Math.round(fit.height)}`;
            resolutionEl.title = LANG('import-too-large');
        } else {
            resolutionEl.innerHTML = `${w} X ${h}`;
            resolutionEl.title = '';
        }
    }
    updateResolution(p.image.width, p.image.height);

    let doFlatten = false;
    function showWarnings (psdWarningArr: TKlPsdError[]): void {
        const contentArr = [];
        const warningMap = {
            'mask': 'Masks not supported. Mask was applied.',
            'clipping': 'Clipping not supported. Clipping layers were merged.',
            'group': 'Groups not supported. Layers were ungrouped.',
            'adjustment': 'Adjustment layers not supported.',
            'layer-effect': 'Layer effects not supported.',
            'smart-object': 'Smart objects not supported.',
            'blend-mode': 'Unsupported layer blend mode.',
            'bits-per-channel': 'Unsupported color depth. Only 8bit per channel supported.',
        };
        for (let i = 0; i < psdWarningArr.length; i++) {
            contentArr.push('- ' + warningMap[psdWarningArr[i]]);
        }
        alert(contentArr.join('\n'));
    }

    let flattenCheckbox: Checkbox;
    let warningsEl: HTMLElement | undefined;
    if (p.image.type === 'psd') {
        if (p.image.layers) {
            flattenCheckbox = new Checkbox({
                init: doFlatten,
                label: LANG('import-flatten'),
                callback: (b): void => {
                    doFlatten = b;
                },
            });
            rootEl.append(flattenCheckbox.getElement());

            if (p.image.warningArr) {
                const noteEl = BB.el({
                    className: 'kl-import-note',
                    content: LANG('import-psd-limited-support'),
                });
                const warnings = p.image.warningArr;
                warningsEl = BB.el({
                    parent: noteEl,
                    tagName: 'a',
                    content: 'Details',
                    css: { marginLeft: '5px'},
                    onClick: () => showWarnings(warnings),
                });
                rootEl.append(noteEl);
            }
        } else {
            const noteEl = BB.el({
                className: 'kl-import-note',
                content: LANG('import-psd-unsupported'),
            });
            rootEl.append(noteEl);
        }
    }

    function callback (result: string): void {
        const croppedImage = cropCopy.getCroppedImage();
        const cropRect = cropCopy.getRect();
        const isCropped = p.image.width !== cropRect.width && p.image.height !== cropRect.height;
        cropCopy.destroy();
        BB.destroyEl(warningsEl);
        if (flattenCheckbox) {
            flattenCheckbox.destroy();
        }

        if (result === LANG('import-btn-as-layer')) {
            p.callback({
                type: 'as-layer',
                image: isCropped ? croppedImage : p.image.canvas,
            });

        } else if (result === LANG('import-btn-as-image')) {
            if (p.image.type === 'psd') {
                if (doFlatten) {
                    delete p.image.layers;
                }
                p.callback({
                    type: 'as-image-psd',
                    image: p.image,
                    cropObj: cropRect,
                });
            } else if (p.image.type === 'image') {
                p.callback({
                    type: 'as-image',
                    image: croppedImage,
                });
            }
        } else {
            p.callback({
                type: 'cancel',
            });
        }
    }
    showModal({
        target: p.target,
        message: `<b>${LANG('import-title')}</b>`,
        div: rootEl,
        style,
        buttons: [LANG('import-btn-as-layer'), LANG('import-btn-as-image'), 'Cancel'],
        primaries: [LANG('import-btn-as-layer'), LANG('import-btn-as-image')],
        callback,
        autoFocus: 'As Image',
    });
}

repo_name: Doggermelon/klecks

license: mit


code:
.viewportWrapper {
  margin-left: -20px;
  box-shadow: rgba(0, 0, 0, 0.2) 0 1px, rgba(0, 0, 0, 0.2) 0 -1px;
  width: calc(100% + 40px);
  height: calc(100vh - 400px);
  min-height: 200px;
  max-height: 600px;
  overflow: hidden;
}

:global(body.kl-theme-dark) {
  .viewportWrapper {
    box-shadow: 0 1px #636363, 0 -1px #636363;
  }
}


code:
import {BB} from '../../../../bb/bb';
import { TRenderTextParam} from '../../../image-operations/render-text';
import {showModal} from '../base/showModal';
import {IRGB} from '../../../kl-types';
import {KlCanvas} from '../../../canvas/kl-canvas';
import {LANG} from '../../../../language/language';
import {TextToolFillUI} from './text-tool-fill-ui';
import {TextToolFontUI} from './text-tool-font-ui';
import {TextToolStrokeUI} from './text-tool-stroke-ui';
import {TextToolTextUI} from './text-tool-text-ui';
import {TextToolViewportUI} from './text-tool-viewport-ui';
import * as classes from './text-tool-dialog.module.scss';
import {c} from '../../../../bb/base/c';
import {css} from '@emotion/css';
import {TabRow} from '../../components/tab-row';

export function textToolDialog (
    p: {
        klCanvas: KlCanvas;
        layerIndex: number;
        primaryColor: IRGB;
        secondaryColor: IRGB;

        text: TRenderTextParam;
        onConfirm: (confirmP: TRenderTextParam) => void;

    }
): void {
    const rootEl = BB.el({});
    let text = BB.copyObj(p.text);

    const viewportWrapper = BB.el({
        className: classes.viewportWrapper,
    });

    const viewportUI = new TextToolViewportUI({
        text: text,
        klCanvas: p.klCanvas,
        layerIndex: p.layerIndex,
        onDragEnd: () => tabs.getOpenedTabId() === 'text' ? textUI.focus() : 0,
    });
    viewportUI.render();

    function onUpdate (p: Partial<TRenderTextParam>) {
        text = {
            ...text,
            ...p,
        };
        viewportUI.setText(text);
    }

    const fontUI = new TextToolFontUI({
        ...text,
        onUpdate: (p) => {
            onUpdate(p);
        },
    });

    const colorWrapper = BB.el({
        css: {
            display: 'flex',
            gap: '10px',
            flexDirection: 'column',
        },
    });
    const fillUI = new TextToolFillUI({
        fill: text.fill,
        primaryColor: p.primaryColor,
        secondaryColor: p.secondaryColor,
        onUpdate: (p) => {
            onUpdate(p);
        },
    });
    const strokeUI = new TextToolStrokeUI({
        stroke: text.stroke,
        primaryColor: p.primaryColor,
        secondaryColor: p.secondaryColor,
        onUpdate: (p) => {
            onUpdate(p);
        },
    });
    const textUI = new TextToolTextUI({
        text: text.text,
        onUpdate: (p) => {
            p.text !== undefined && modal.setIgnoreBackground(p.text.length > 0);
            onUpdate(p);
        },
    });

    const tabs = new TabRow({
        initialId: 'text',
        height: 40,
        tabArr: [
            {
                id: 'text',
                label: LANG('text-text'),
                onOpen: () => {
                    textUI.getElement().style.display = '';
                    textUI.focus();
                },
                onClose: () => {
                    textUI.getElement().style.display = 'none';
                },
            },
            {
                id: 'font',
                label: LANG('text-font'),
                onOpen: () => {
                    fontUI.getElement().style.display = 'flex';
                },
                onClose: () => {
                    fontUI.getElement().style.display = 'none';
                },
            },
            {
                id: 'color',
                label: LANG('text-color'),
                onOpen: () => {
                    colorWrapper.style.display = 'flex';
                },
                onClose: () => {
                    colorWrapper.style.display = 'none';
                },
            },
        ],
    });
    tabs.getElement().classList.add(css({
        minWidth: '200px',
        maxWidth: '500px',
        flexGrow: '1',
        borderBottom: 'none !important',
    }));

    const viewportInputsCss = css({
        position: 'relative',
        '>*': {
            position: 'absolute',
            right: 0,
            top: 10,
        },
    });

    const tabWrapperCss = css({
        display: 'flex',
        justifyContent: 'space-between',
        flexDirection: 'row-reverse',
        flexWrap: 'wrap',
        marginTop: '10px',
        marginLeft: '-20px',
        marginRight: '-20px',
        padding: '0 20px',
        '@media (min-width: 700px) and (min-height: 700px)': {
            display: 'none !important',
        },
    });

    const inputsCss = css({
        minHeight: 72,
        marginTop: '10px',
        '@media (min-width: 700px) and (min-height: 700px)': {
            display: 'flex',
            flexDirection: 'column',
            gap: '10px',
            '>*': {
                display: 'flex !important',
            },
        },
    });

    rootEl.append(c('', [
        c(viewportWrapper, [viewportUI.getElement()]),
        c('.' + viewportInputsCss, [viewportUI.getInputsElement()]),
        c('.tabrow.' + tabWrapperCss, [
            c(',w-240,h-40'),
            tabs.getElement(),
        ]),

        c('.' + inputsCss, [
            c(colorWrapper, [
                fillUI.getElement(),
                strokeUI.getElement(),
            ]),
            fontUI.getElement(),
            textUI.getElement(),
        ]),
    ]));

    const onResize = () => {
        const b = viewportWrapper.getBoundingClientRect();
        const w = Math.ceil(b.width);
        const h = Math.ceil(b.height);
        viewportUI.setSize(w, h);
    };
    window.addEventListener('resize', onResize);
    setTimeout(() => {
        onResize();
    });
    setTimeout(() => {
        textUI.focus();
    }, 100);

    // prevent mobile keyboards scrolling page
    function onScroll (): void {
        window.scrollTo(0, 0);
    }
    window.addEventListener('scroll', onScroll);

    const onModalExit = (val: string) => {
        window.removeEventListener('resize', onResize);
        window.removeEventListener('scroll', onScroll);
        viewportUI.destroy();
        fontUI.destroy();
        fillUI.destroy();
        strokeUI.destroy();
        textUI.destroy();
        tabs.destroy();

        if (val === 'Ok') {
            p.onConfirm({
                ...viewportUI.getValues(),
                ...fontUI.getValues(),
                ...fillUI.getValues(),
                ...strokeUI.getValues(),
                ...textUI.getValues(),
            });
        }
    };

    const modal = showModal({
        target: document.body,
        message: `<b>${LANG('text-title')}</b>`,
        div: rootEl,
        buttons: ['Ok', 'Cancel'],
        ignoreBackground: p.text.text.length > 0,
        callback: onModalExit,
        style: {
            width: 'calc(100% - 50px)',
            maxWidth: '1000px',
            minWidth: '300px',
            boxSizing: 'border-box',
        },
        clickOnEnter: 'Ok',
    });
}


code:
import {TRenderTextParam} from '../../../image-operations/render-text';
import {IRGB, IRGBA} from '../../../kl-types';
import {ColorOptions} from '../../components/color-options';
import {KlSlider} from '../../components/kl-slider';
import {LANG} from '../../../../language/language';
import {c} from '../../../../bb/base/c';
import {createSvg} from '../../../../bb/base/base';

type TFillParams = Pick<
    TRenderTextParam,
    'fill'
>;

export type TFillUIParams = TFillParams & {
    primaryColor: IRGB;
    secondaryColor: IRGB;
    onUpdate: (v: Partial<TFillParams>) => void;
};

export class TextToolFillUI {
    private readonly rootEl: HTMLElement;

    private readonly colorOptions: ColorOptions;
    private readonly opacitySlider: KlSlider;

    // ---- public ----
    constructor (p: TFillUIParams) {

        const emit = (): void => {
            p.onUpdate(this.getValues());
        };

        const colorOptionsArr: (IRGBA | null)[] = [
            null,
            p.fill ? {
                ...p.fill.color,
                a: 1,
            } : null,
            {
                ...p.primaryColor,
                a: 1,
            },
            {
                ...p.secondaryColor,
                a: 1,
            },
            {r: 0, g: 0, b: 0, a: 1},
            {r: 255, g: 255, b: 255, a: 1},
        ];

        this.colorOptions = new ColorOptions({
            colorArr: colorOptionsArr,
            initialIndex: p.fill ? 1 : 0,
            title: LANG('shape-fill'),
            onChange: (c) => {
                this.opacitySlider.getElement().style.display = c !== null ? '' : 'none';
                emit();
            },
        });

        this.opacitySlider = new KlSlider({
            label: LANG('opacity'),
            width: 150,
            height: 30,
            min: 1 / 100,
            max: 1,
            value: p.fill ? p.fill.color.a : 1,
            resolution: 225,
            eventResMs: 1000 / 30,
            toValue: (displayValue) => displayValue / 100,
            toDisplayValue: (value) => value * 100,
            onChange: () => emit(),
        });
        this.opacitySlider.getElement().style.display = p.fill ? '' : 'none';

        const svg = createSvg({
            elementType: 'svg',
            class: 'dark-invert',
            width: '25px',
            height: '25px',
            viewBox: '0 0 30 30',

            childrenArr: [
                {
                    elementType: 'polyline',
                    points: '0,0 30,0 30,8 19,8 19,30 11,30 11,8 0,8 0,0 1,0',
                    'transform-origin': '15px 15px',
                    transform: 'scale(0.8)',
                    fill: '#000c',
                },
            ],
        });

        this.rootEl = c(',flex,gap-10,items-center,flexWrap,minh-30', [
            svg,
            this.colorOptions.getElement(),
            this.opacitySlider.getElement(),
        ]);
    }

    getElement (): HTMLElement {
        return this.rootEl;
    }

    getValues (): TFillParams {
        const c = this.colorOptions.getValue();
        if (c === null || c?.a === 0) {
            return {
                fill: undefined,
            };
        }
        const color = {
            ...c,
            a: this.opacitySlider.getValue(),
        };
        return {
            fill: {
                color,
            },
        };
    }

    destroy (): void {
        this.colorOptions.destroy();
        this.opacitySlider.destroy();
    }
}


code:
import {BB} from '../../../../bb/bb';
import {TRenderTextParam, TTextFormat} from '../../../image-operations/render-text';
import {Input} from '../../components/input';
import {LANG} from '../../../../language/language';
import {ImageRadioList} from '../../components/image-radio-list';
import alignLeftImg from '/src/app/img/ui/align-left.svg';
import alignCenterImg from '/src/app/img/ui/align-center.svg';
import alignRightImg from '/src/app/img/ui/align-right.svg';
import typoItalicImg from '/src/app/img/ui/typo-italic.svg';
import typoBoldImg from '/src/app/img/ui/typo-bold.svg';
import {ImageToggle} from '../../components/image-toggle';
import {Select} from '../../components/select';
import {c} from '../../../../bb/base/c';
import {PointerListener} from '../../../../bb/input/pointer-listener';
import {fonts} from '../../../../../fonts/fonts';

type TFontParams = Pick<
    TRenderTextParam,
    'font' | 'size' | 'letterSpacing' | 'lineHeight' | 'align' | 'isItalic' | 'isBold'
>;

export type TFontUIParams = TFontParams & {
    onUpdate: (v: Partial<TFontParams>) => void;
};

const importedFonts: {
    fontFamily: string;
    fontName: string; // visible to user
}[] = [
    {fontFamily: 'sans-serif', fontName: 'Sans-serif'},
    {fontFamily: 'serif', fontName: 'Serif'},
    {fontFamily: 'monospace', fontName: 'Monospace'},
    {fontFamily: 'cursive', fontName: 'Cursive'},
    {fontFamily: 'fantasy', fontName: 'Fantasy'},
    ...fonts.map(item => {
        return {
            fontFamily: item.name,
            fontName: item.name,
        };
    }),
];

let didLoadBundledFonts = false;
async function loadBundledFonts (): Promise<void> {
    if (didLoadBundledFonts) {
        return;
    }
    didLoadBundledFonts = true;
    const promises: Promise<void>[] = [];
    for (let i = 0; i < fonts.length; i++) {
        promises.push((async () => {
            const item = fonts[i];
            const response = await fetch(item.url);
            const buffer = await response.arrayBuffer();
            const font = new FontFace(item.name, buffer);
            document.fonts.add(font);
        })());
    }
    await Promise.all(promises);
}

export class TextToolFontUI {
    private readonly rootEl: HTMLElement;

    private readonly fontSelect: Select<string>;
    private readonly fontPointerListener: PointerListener;

    private readonly importButton: HTMLButtonElement;

    private readonly sizeInput: Input;
    private readonly lineHeightInput: Input;
    private readonly letterSpacingInput: Input;

    private readonly alignRadioList: ImageRadioList<TTextFormat>;
    private readonly italicToggle: ImageToggle;
    private readonly boldToggle: ImageToggle;

    private readonly onUpdate: (v: Partial<TFontParams>) => void;
    private readonly onFocus: () => void;

    // only load fonts when interacting with the font input
    private loadBundledFonts (): void {
        !didLoadBundledFonts && loadBundledFonts().then(() => {
            this.onUpdate({
                font: this.fontSelect.getValue(),
            });
        });
    }

    private importFont (): void {
        const input = document.createElement('input');
        input.type = 'file';
        input.multiple = true;
        input.accept = '.ttf, .otf, .woff, .woff2, .eot';
        input.focus();
        input.click();
        input.onchange = async () => {
            if (!input.files || input.files.length === 0) {
                return;
            }
            let toSelect: string | undefined = undefined;

            const fontFamilies = importedFonts.map(i => i.fontFamily);
            for (let i = 0; i < input.files.length; i++) {
                const file = input.files[i];
                const fontName = 'kl-' + importedFonts.length;
                if (fontFamilies.includes(fontName)) {
                    continue;
                }
                if (!toSelect) {
                    toSelect = fontName;
                    this.importButton.disabled = true;
                    this.importButton.innerText = LANG('loading');
                }

                const split = file.name.split('.');
                split.pop();

                importedFonts.push({
                    fontFamily: fontName,
                    fontName: split.join('.'),
                });
                const font = new FontFace(fontName, await file.arrayBuffer());
                document.fonts.add(font);
            }

            // update font selection
            setTimeout(() => {
                this.fontSelect.setOptionArr(importedFonts.map(i => {
                    return [
                        i.fontFamily,
                        i.fontName,
                        {
                            css: {
                                fontFamily: i.fontFamily,
                                fontSize: '1.2em',
                            },
                        },
                    ];
                }));
                this.fontSelect.setValue(toSelect);
                this.onUpdate({
                    font: toSelect,
                });
                this.importButton.disabled = false;
                this.importButton.innerText = LANG('file-import');
            });
        };
    }

    // ---- public ----
    constructor (p: TFontUIParams) {
        this.onUpdate = p.onUpdate;

        this.fontSelect = new Select<string>({
            initValue: p.font,
            optionArr: importedFonts.map(i => {
                return [
                    i.fontFamily,
                    i.fontName,
                    {
                        css: {
                            fontFamily: i.fontFamily,
                            fontSize: '1.2em',
                        },
                    },
                ];
            }),
            isFocusable: true,
            css: {
                width: '180px',
            },
            onChange: (v) => {
                this.loadBundledFonts();
                p.onUpdate({
                    font: v,
                });
            },
        });
        this.onFocus = () => this.loadBundledFonts();
        this.fontSelect.getElement().addEventListener('focus', this.onFocus);
        this.fontPointerListener = new BB.PointerListener({
            target: this.fontSelect.getElement(),
            onWheel: (e) => this.fontSelect.setDeltaValue(e.deltaY),
        });

        this.importButton = BB.el({
            tagName: 'button',
            content: LANG('file-import'),
            onClick: () => {
                this.importFont();
            },
        });

        const sizeLabel = BB.el({
            content: '
AA
',
            css: {
                width: '25px',
                height: '25px',
                display: 'flex',
                justifyContent: 'center',
                alignItems: 'center',
                fontSize: '1.2em',
                userSelect: 'none',
            },
        });
        this.sizeInput = new Input({
            title: LANG('text-size'),
            type: 'number',
            min: 0,
            max: 1000,
            init: p.size,
            onChange: (v) => {
                p.onUpdate({
                    size: parseFloat(v),
                });
            },
            doResetIfInvalid: true,
            doScrollWithoutFocus: true,
            css: {
                width: '70px',
            },
        });

        this.lineHeightInput = new Input({
            label: LANG('text-line-height'),
            type: 'number',
            min: 0,
            max: 10,
            step: 0.1,
            init: p.lineHeight ?? 1,
            onChange: (v) => {
                p.onUpdate({
                    lineHeight: parseFloat(v),
                });
            },
            doResetIfInvalid: true,
            doScrollWithoutFocus: true,
            css: {
                width: '60px',
            },
        });

        this.letterSpacingInput = new Input({
            label: LANG('text-letter-spacing'),
            type: 'number',
            min: -100,
            max: 100,
            init: p.letterSpacing ?? 0,
            onChange: (v) => {
                p.onUpdate({
                    letterSpacing: parseFloat(v),
                });
            },
            doResetIfInvalid: true,
            doScrollWithoutFocus: true,
            css: {
                width: '60px',
            },
        });

        this.alignRadioList = new ImageRadioList<TTextFormat>({
            optionArr: [
                {
                    id: 'left',
                    title: LANG('text-left'),
                    image: alignLeftImg,
                    darkInvert: true,
                },
                {
                    id: 'center',
                    title: LANG('text-center'),
                    image: alignCenterImg,
                    darkInvert: true,
                },
                {
                    id: 'right',
                    title: LANG('text-right'),
                    image: alignRightImg,
                    darkInvert: true,
                },
            ],
            initId: p.align,
            onChange: (v) => p.onUpdate({
                align: v,
            }),
        });

        this.italicToggle = new ImageToggle({
            image: typoItalicImg,
            title: LANG('text-italic'),
            initValue: p.isItalic,
            onChange: (v) => p.onUpdate({
                isItalic: v,
            }),
            darkInvert: true,
        });

        this.boldToggle = new ImageToggle({
            image: typoBoldImg,
            title: LANG('text-bold'),
            initValue: p.isBold,
            onChange: (v) => p.onUpdate({
                isBold: v,
            }),
            darkInvert: true,
        });

        this.rootEl = c(',flex,flexWrap,gap-10-15,items-center', [
            c(',flex,gap-5,items-center', [
                sizeLabel,
                this.sizeInput.getElement(),
            ]),
            c(',flex,gap-5', [
                this.fontSelect.getElement(),
                this.importButton,
            ]),
            c(',flex,gap-7', [
                this.alignRadioList.getElement(),
                this.italicToggle.getElement(),
                this.boldToggle.getElement(),
            ]),
            this.letterSpacingInput.getElement(),
            this.lineHeightInput.getElement(),
        ]);
    }

    getElement (): HTMLElement {
        return this.rootEl;
    }

    getValues (): TFontParams {
        return {
            font: this.fontSelect.getValue(),
            size: +this.sizeInput.getValue(),
            letterSpacing: +this.letterSpacingInput.getValue(),
            lineHeight: +this.lineHeightInput.getValue(),
            align: this.alignRadioList.getValue(),
            isItalic: this.italicToggle.getValue(),
            isBold: this.boldToggle.getValue(),
        };
    }

    destroy (): void {
        this.fontPointerListener.destroy();
        this.fontSelect.getElement().removeEventListener('focus', this.onFocus);
        this.fontSelect.destroy();
        BB.destroyEl(this.importButton);
        this.sizeInput.destroy();
        this.lineHeightInput.destroy();
        this.letterSpacingInput.destroy();
        this.alignRadioList.destroy();
        this.italicToggle.destroy();
        this.boldToggle.destroy();
    }
}


code:
import {BB} from '../../../../bb/bb';
import {TRenderTextParam} from '../../../image-operations/render-text';
import {IRGB, IRGBA} from '../../../kl-types';
import {ColorOptions} from '../../components/color-options';
import {KlSlider} from '../../components/kl-slider';
import {LANG} from '../../../../language/language';
import {c} from '../../../../bb/base/c';
import {createSvg} from '../../../../bb/base/base';

type TStrokeParams = Pick<
    TRenderTextParam,
    'stroke'
>;

export type TStrokeUIParams = TStrokeParams & {
    primaryColor: IRGB;
    secondaryColor: IRGB;
    onUpdate: (v: Partial<TStrokeParams>) => void;
};

export class TextToolStrokeUI {
    private readonly rootEl: HTMLElement;

    private readonly colorOptions: ColorOptions;
    private readonly opacitySlider: KlSlider;
    private readonly lineWidthSlider: KlSlider;

    // ---- public ----
    constructor (p: TStrokeUIParams) {

        this.rootEl = BB.el({
            css: {
                boxShadow: '0 0 0 1px #aaa',
            },
        });

        const emit = (): void => {
            p.onUpdate(this.getValues());
        };

        const colorOptionsArr: (IRGBA | null)[] = [
            null,
            p.stroke ? {
                ...p.stroke.color,
                a: 1,
            } : null,
            {
                ...p.secondaryColor,
                a: 1,
            },
            {
                ...p.primaryColor,
                a: 1,
            },
            {r: 255, g: 255, b: 255, a: 1},
            {r: 0, g: 0, b: 0, a: 1},
        ];

        this.colorOptions = new ColorOptions({
            colorArr: colorOptionsArr,
            initialIndex: p.stroke ? 1 : 0,
            title: LANG('shape-stroke'),
            onChange: (c) => {
                this.opacitySlider.getElement().style.display = c !== null ? '' : 'none';
                this.lineWidthSlider.getElement().style.display = c !== null ? '' : 'none';
                emit();
            },
        });

        this.opacitySlider = new KlSlider({
            label: LANG('opacity'),
            width: 150,
            height: 30,
            min: 1 / 100,
            max: 1,
            value: p.stroke ? p.stroke.color.a : 1,
            resolution: 225,
            eventResMs: 1000 / 30,
            toValue: (displayValue) => displayValue / 100,
            toDisplayValue: (value) => value * 100,
            onChange: () => emit(),
        });

        this.lineWidthSlider = new KlSlider({
            label: LANG('shape-line-width'),
            width: 150,
            height: 30,
            min: 0.5,
            max: 100,
            value: p.stroke ? p.stroke.lineWidth : 2,
            eventResMs: 1000 / 30,
            curve: 'quadratic',
            onChange: () => emit(),
        });

        this.opacitySlider.getElement().style.display = p.stroke ? '' : 'none';
        this.lineWidthSlider.getElement().style.display = p.stroke ? '' : 'none';

        const svg = createSvg({
            elementType: 'svg',
            class: 'dark-invert',
            width: '25px',
            height: '25px',
            viewBox: '0 0 30 30',

            childrenArr: [
                {
                    elementType: 'polyline',
                    points: '0,0 30,0 30,8 19,8 19,30 11,30 11,8 0,8 0,0 1,0',
                    fill: 'none',
                    stroke: '#000c',
                    'stroke-width': '3',
                    'transform-origin': '15px 15px',
                    transform: 'scale(0.8)',
                },
            ],
        });

        this.rootEl = c(',flex,gap-10,items-center,flexWrap,minh-30', [
            svg,
            this.colorOptions.getElement(),
            this.opacitySlider.getElement(),
            this.lineWidthSlider.getElement(),
        ]);
    }

    getElement (): HTMLElement {
        return this.rootEl;
    }

    getValues (): TStrokeParams {
        const c = this.colorOptions.getValue();
        if (c === null || c?.a === 0) {
            return {
                stroke: undefined,
            };
        }
        const color = {
            ...c,
            a: this.opacitySlider.getValue(),
        };
        return {
            stroke: {
                color,
                lineWidth: this.lineWidthSlider.getValue(),
            },
        };
    }

    destroy (): void {
        this.colorOptions.destroy();
        this.opacitySlider.destroy();
        this.lineWidthSlider.destroy();
    }
}


code:
import {TRenderTextParam} from '../../../image-operations/render-text';
import {BB} from '../../../../bb/bb';
import {LANG} from '../../../../language/language';
import {c} from '../../../../bb/base/c';

type TTextParams = Pick<
    TRenderTextParam,
    'text'
>;

export type TTextUIParams = TTextParams & {
    onUpdate: (v: Partial<TTextParams>) => void;
};

export class TextToolTextUI {
    private readonly rootEl: HTMLElement;

    private readonly textInput: HTMLTextAreaElement;
    private lastEmittedText: string;
    private readonly onUpdate: (v: Partial<TTextParams>) => void;

    private readonly onInput = (): void => {
        this.emit();
    };

    private emit (): void {
        const text = this.textInput.value;
        if (text === this.lastEmittedText) {
            return;
        }
        this.lastEmittedText = text;
        this.onUpdate({
            text,
        });
    }

    // ---- public ----
    constructor (p: TTextUIParams) {
        this.lastEmittedText = p.text;
        this.onUpdate = p.onUpdate;

        this.rootEl = c('');

        this.textInput = BB.el({
            tagName: 'textarea',
            parent: this.rootEl,
            content: p.text,
            custom: {
                placeholder: LANG('text-placeholder'),
            },
            css: {
                whiteSpace: 'pre',
                overflow: 'auto',
                width: '100%',
                height: '70px',
                resize: 'vertical',
            },
            onChange: () => {
                this.emit();
            },
        });

        this.textInput.addEventListener('input', this.onInput);
    }

    getElement (): HTMLElement {
        return this.rootEl;
    }

    getValues (): TTextParams {
        return {
            text: this.textInput.value,
        };
    }

    focus (): void {
        this.textInput.focus();
    }

    destroy (): void {
        BB.destroyEl(this.textInput);
        this.textInput.removeEventListener('input', this.onInput);
    }
}


code:
import {BB} from '../../../../bb/bb';
import {throwIfNull} from '../../../../bb/base/base';
import {theme} from '../../../../theme/theme';
import {renderText, TRenderTextParam} from '../../../image-operations/render-text';
import {KlCanvas} from '../../../canvas/kl-canvas';
import {KlSlider} from '../../components/kl-slider';
import {PointerListener} from '../../../../bb/input/pointer-listener';
import {LANG} from '../../../../language/language';
import toolZoomInImg from '/src/app/img/ui/tool-zoom-in.svg';
import toolZoomOutImg from '/src/app/img/ui/tool-zoom-out.svg';
import {c} from '../../../../bb/base/c';
import {IVector2D} from '../../../../bb/bb-types';
import {KeyListener} from '../../../../bb/input/key-listener';

type TViewportParams = Pick<
    TRenderTextParam,
    'x' | 'y' | 'angleRad'
>;

type TViewportUIParams = {
    text: TRenderTextParam;
    klCanvas: KlCanvas;
    layerIndex: number;
    onDragEnd: () => void;
};

export class TextToolViewportUI {

    private readonly rootEl: HTMLElement;
    private readonly inputsRootEl: HTMLElement;
    private readonly previewWrapper: HTMLElement;
    private text: TRenderTextParam;
    private offset: IVector2D = {x: 0, y: 0};
    private interval: ReturnType<typeof setInterval> | undefined;

    private width: number;
    private height: number;
    private zoomFac: number = 0;
    private scale: number = 1;

    private readonly layerArr: ReturnType<KlCanvas['getLayersFast']> = [];
    private readonly layerIndex: number;

    private readonly textCanvas: HTMLCanvasElement;
    private readonly textCtx: CanvasRenderingContext2D;

    private readonly targetCanvas: HTMLCanvasElement;
    private readonly targetCtx: CanvasRenderingContext2D;

    private readonly layersCanvas: HTMLCanvasElement;
    private readonly layersCtx: CanvasRenderingContext2D;

    private readonly previewCanvas: HTMLCanvasElement;
    private readonly previewCtx: CanvasRenderingContext2D;

    private readonly emptyCanvas: HTMLCanvasElement;
    private readonly emptyCanvasLight: HTMLCanvasElement;

    private checkerPattern: CanvasPattern;

    private readonly rotationSlider: KlSlider;
    private readonly zoomInBtn: HTMLButtonElement;
    private readonly zoomOutBtn: HTMLButtonElement;

    private readonly eventCapture: HTMLElement;

    private readonly previewPointerListener: PointerListener;
    private readonly keyListener: KeyListener;

    private readonly onDarkChange = () => {
        this.checkerPattern = throwIfNull(this.previewCtx.createPattern(BB.createCheckerCanvas(8, theme.isDark()), 'repeat'));
        this.render();
    };

    private canZoom (d: number): boolean {
        return this.zoomFac !== Math.min(2, Math.max(-2, this.zoomFac + d));
    }

    private changeZoomFac (d: number): void {
        this.zoomFac = Math.min(2, Math.max(-2, this.zoomFac + d));
        this.render();
        this.zoomInBtn.disabled = !this.canZoom(1);
        this.zoomOutBtn.disabled = !this.canZoom(-1);
    }

    /** Move text by x y **/
    private move (x: number, y: number): void {
        const rotated = BB.rotate(x, y, -this.rotationSlider.getValue() / Math.PI * 180);
        this.text.x += rotated.x / this.scale;
        this.text.y += rotated.y / this.scale;
        this.render();
    }

    // ---- public ----
    constructor (p: TViewportUIParams) {
        this.rootEl = c();
        this.text = p.text;
        this.layerIndex = p.layerIndex;

        const isSmallWidth = window.innerWidth < 550;
        const isSmallHeight = window.innerHeight < 630;

        // --- preview ---
        // Text drawn on klCanvas-sized canvas: textCanvas
        // LayerArr[target].canvas & textCanvas then drawn on targetCanvas
        //      they are transformed. canvas size of final preview
        // All layers and targetCanvas drawn on layersCanvas. transformed and size of final preview
        // Checkerboard, layersCanvas, and outline then drawn on previewCanvas

        this.width = isSmallWidth ? 340 : 540;
        this.height = isSmallWidth ? (isSmallHeight ? 210 : 260) : (isSmallHeight ? 230 : 350);

        this.layerArr = p.klCanvas.getLayersFast();
        this.textCanvas = BB.canvas(p.klCanvas.getWidth(), p.klCanvas.getHeight());
        this.textCtx = BB.ctx(this.textCanvas);
        this.targetCanvas = BB.canvas(this.width, this.height);
        this.targetCtx = BB.ctx(this.targetCanvas);
        this.layersCanvas = BB.canvas(this.width, this.height);
        this.layersCtx = BB.ctx(this.layersCanvas);
        this.previewCanvas = BB.canvas(this.width, this.height); // the one that is visible
        this.previewCtx = BB.ctx(this.previewCanvas);
        BB.css(this.previewCanvas, {
            display: 'block',
        });
        this.previewWrapper = BB.el({
            parent: this.rootEl,
            css: {
                position: 'relative',
                width: this.width + 'px',
                cursor: 'move',
                touchAction: 'none',
            },
        });
        BB.el({ // inset shadow on preview
            parent: this.previewWrapper,
            className: 'kl-text-preview-wrapper',
        });
        this.previewWrapper.append(this.previewCanvas);
        this.checkerPattern = throwIfNull(this.previewCtx.createPattern(BB.createCheckerCanvas(8, theme.isDark()), 'repeat'));
        this.emptyCanvas = BB.canvas(1, 1);
        this.emptyCanvasLight = BB.canvas(1, 1);
        {
            let ctx = BB.ctx(this.emptyCanvas);
            ctx.fillRect(0, 0, 1, 1);

            ctx = BB.ctx(this.emptyCanvasLight);
            ctx.fillStyle = '#fff';
            ctx.fillRect(0, 0, 1, 1);
        }

        theme.addIsDarkListener(this.onDarkChange);

        this.previewCanvas.oncontextmenu = (e) => e.preventDefault();
        let dragged = false;
        let isDown = false;
        let offsetDragged = false;
        this.previewPointerListener = new BB.PointerListener({
            target: this.previewCanvas,
            onPointer: (e) => {

                // drag detect
                if (e.type === 'pointerdown') {
                    dragged = false;
                    isDown = true;
                }
                if (e.type === 'pointermove' && isDown) {
                    dragged = true;
                }
                if (e.type === 'pointerup') {
                    if (isDown && dragged && e.pointerType === 'mouse') {
                        // With touch/pen it would be annoying to focus again,
                        // because it probably pops out the keyboard.
                        p.onDragEnd();
                    }
                    dragged = false;
                    isDown = false;
                }

                if (e.type === 'pointermove' && e.button === 'left') {
                    e.eventPreventDefault();
                    this.offset = {x: 0, y: 0};
                    this.move(-e.dX, -e.dY);
                }
                if (e.type === 'pointerdown' && e.button === 'right') {
                    document.body.append(this.eventCapture);
                }
                if (e.type === 'pointerup') {
                    setTimeout(() => this.eventCapture.remove(), 20);
                }
                if (e.type === 'pointermove' && e.button === 'right') {
                    offsetDragged = true;
                    e.eventPreventDefault();
                    this.offset.x -= e.dX;
                    this.offset.y -= e.dY;
                    this.render();
                }
                if (e.type === 'pointerup' && offsetDragged) {
                    let count = 0;
                    this.interval = setInterval(() => {
                        if (count > 8) {
                            clearInterval(this.interval);
                            this.offset = {x: 0, y: 0};
                            this.render();
                        }
                        this.offset = {x: this.offset.x * 0.6, y: this.offset.y * 0.6};
                        this.render();
                        count++;
                    }, 10);

                    this.offset = {x: this.offset.x * 0.6, y: this.offset.y * 0.6};
                    this.render();
                }
            },
            onWheel: (e) => {
                this.changeZoomFac(-e.deltaY);
            },
        });

        const wheelPrevent = (event: WheelEvent): void => event.preventDefault();
        this.previewCanvas.addEventListener('wheel', wheelPrevent);

        this.rotationSlider = new KlSlider({
            label: LANG('filter-transform-rotation'),
            width: 150,
            height: 30,
            min: -Math.PI,
            max: Math.PI,
            value: p.text.angleRad,
            resolution: 225,
            // eventResMs: 1000 / 30,
            toValue: (deg) => deg * Math.PI / 180,
            toDisplayValue: (rad) => rad / Math.PI * 180,
            onChange: () => {
                this.offset = {x: 0, y: 0};
                this.render();
            },
            unit: '°',
        });

        this.zoomInBtn = BB.el({
            tagName: 'button',
            content: `<img height="20" src="${toolZoomInImg}">`,
            title: LANG('zoom-in'),
            onClick: () => this.changeZoomFac(1),
            css: {
                fontWeight: 'bold',
            },
        });
        this.zoomOutBtn = BB.el({
            tagName: 'button',
            content: `<img height="20" src="${toolZoomOutImg}">`,
            title: LANG('zoom-out'),
            onClick: () => this.changeZoomFac(-1),
            css: {
                fontWeight: 'bold',
            },
        });

        this.eventCapture = BB.el({
            css: {
                position: 'absolute',
                left: '0',
                top: '0',
                right: '0',
                bottom: '0',
                zIndex: '999',
                cursor: 'move',
            },
        });
        this.eventCapture.oncontextmenu = (e) => e.preventDefault();

        this.keyListener = new BB.KeyListener({
            onDown: (keyStr) => {
                if (BB.isInputFocused(true)) {
                    return;
                }
                const factor = this.keyListener.isPressed('shift') ? 4 : 1;
                if (keyStr === 'left') {
                    this.move(-factor, 0);
                }
                if (keyStr === 'right') {
                    this.move(factor, 0);
                }
                if (keyStr === 'up') {
                    this.move(0, -factor);
                }
                if (keyStr === 'down') {
                    this.move(0, factor);
                }
            },
        });

        this.inputsRootEl = c(',flex,gap-5', [
            this.rotationSlider.getElement(),
            c(),
            this.zoomInBtn,
            this.zoomOutBtn,
        ]);
    }

    render (): void {
        // try to draw very much like klCanvasWorkspace

        const angleRad = this.rotationSlider.getValue();

        // --- draw text ---
        this.textCtx.clearRect(0, 0, this.textCanvas.width, this.textCanvas.height);
        const bounds = renderText(this.textCanvas, {
            ...this.text,
            x: this.text.x,
            y: this.text.y,
            angleRad: this.rotationSlider.getValue(),
        });

        // transform offset
        const transformedOffset = BB.Vec2.mul(
            BB.rotate(this.offset.x, this.offset.y, -angleRad / Math.PI * 180),
            1 / this.scale,
        );

        // --- determine transformation of viewport ---
        // text should always be visible
        bounds.width = Math.max(bounds.width, 1);
        bounds.height = Math.max(bounds.height, 1);
        const rotatedXY = BB.rotate(bounds.x, bounds.y, -angleRad / Math.PI * 180);
        const rotatedWH = BB.rotate(bounds.width, bounds.height, -angleRad / Math.PI * 180);
        const centerX = this.text.x + rotatedXY.x + rotatedWH.x / 2 + transformedOffset.x;
        const centerY = this.text.y + rotatedXY.y + rotatedWH.y / 2 + transformedOffset.y;

        const padding = 100;
        const fitBounds = BB.fitInto(
            bounds.width, bounds.height,
            this.width - padding,
            this.height - padding
        );
        this.scale = Math.min(1, fitBounds.width / bounds.width);
        this.scale = Math.min(4, this.scale * Math.pow(2, this.zoomFac));

        // --- compose text and target layer ---
        this.targetCtx.save();

        if (this.scale >= 4) {
            this.targetCtx.imageSmoothingEnabled = false;
        } else {
            this.targetCtx.imageSmoothingEnabled = true;
            this.targetCtx.imageSmoothingQuality  = this.scale >= 1 ? 'low' : 'medium';
        }

        this.targetCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
        this.targetCtx.translate(this.width / 2, this.height / 2);
        this.targetCtx.scale(this.scale, this.scale);
        this.targetCtx.rotate(angleRad);
        this.targetCtx.drawImage(this.layerArr[this.layerIndex].canvas, -centerX, -centerY);
        this.targetCtx.drawImage(this.textCanvas, -centerX, -centerY);
        this.targetCtx.restore();

        const isDark = theme.isDark();

        // --- layers ---
        this.layersCtx.save();

        this.layersCtx.fillStyle = isDark ? 'rgb(33,33,33)' : 'rgb(158,158,158)';
        this.layersCtx.fillRect(0, 0, this.width, this.height);

        { // bg
            this.layersCtx.save();

            this.layersCtx.translate(this.width / 2, this.height / 2);
            this.layersCtx.scale(this.scale, this.scale);
            this.layersCtx.rotate(angleRad);

            this.layersCtx.imageSmoothingEnabled = false;

            //outline
            const borderSize = 1 / this.scale;
            this.layersCtx.globalAlpha = isDark ? 0.25 : 0.2;
            this.layersCtx.drawImage(
                isDark ? this.emptyCanvasLight : this.emptyCanvas,
                -centerX - borderSize,
                -centerY - borderSize,
                this.textCanvas.width + borderSize * 2,
                this.textCanvas.height + borderSize * 2,
            );
            this.layersCtx.globalAlpha = 1;

            //erase
            this.layersCtx.globalCompositeOperation = 'destination-out';
            this.layersCtx.drawImage(this.emptyCanvas, -centerX, -centerY, this.textCanvas.width, this.textCanvas.height);

            this.layersCtx.restore();
        }

        { // individual layers

            if (this.scale >= 4) {
                this.layersCtx.imageSmoothingEnabled = false;
            } else {
                this.layersCtx.imageSmoothingEnabled = true;
                this.layersCtx.imageSmoothingQuality  = this.scale >= 1 ? 'low' : 'medium';
            }

            // layers below
            this.layersCtx.save();
            this.layersCtx.translate(this.width / 2, this.height / 2);
            this.layersCtx.scale(this.scale, this.scale);
            this.layersCtx.rotate(angleRad);
            for (let i = 0; i < this.layerIndex; i++) {
                if (this.layerArr[i].isVisible && this.layerArr[i].opacity > 0) {
                    this.layersCtx.globalAlpha = this.layerArr[i].opacity;
                    this.layersCtx.globalCompositeOperation = this.layerArr[i].mixModeStr;
                    this.layersCtx.drawImage(this.layerArr[i].canvas, -centerX, -centerY);
                }
            }
            this.layersCtx.restore();

            // target layer
            this.layersCtx.globalAlpha = (
                this.layerArr[this.layerIndex].opacity *
                (this.layerArr[this.layerIndex].isVisible ? 1 : 0)
            );
            this.layersCtx.globalCompositeOperation = this.layerArr[this.layerIndex].mixModeStr;
            this.layersCtx.drawImage(this.targetCanvas, 0, 0);

            // layers above
            this.layersCtx.save();
            this.layersCtx.translate(this.width / 2, this.height / 2);
            this.layersCtx.scale(this.scale, this.scale);
            this.layersCtx.rotate(angleRad);
            for (let i = this.layerIndex + 1; i < this.layerArr.length; i++) {
                if (this.layerArr[i].isVisible && this.layerArr[i].opacity > 0) {
                    this.layersCtx.globalAlpha = this.layerArr[i].opacity;
                    this.layersCtx.globalCompositeOperation = this.layerArr[i].mixModeStr;
                    this.layersCtx.drawImage(this.layerArr[i].canvas, -centerX, -centerY);
                }
            }
            this.layersCtx.restore();

        }

        this.layersCtx.restore();

        // --- final composite ---
        this.previewCtx.save();
        this.previewCtx.fillStyle = this.checkerPattern;
        this.previewCtx.fillRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
        this.previewCtx.drawImage(this.layersCanvas, 0, 0);
        this.previewCtx.restore();

        // bounds
        this.previewCtx.save();
        this.previewCtx.globalCompositeOperation = 'difference';
        this.previewCtx.strokeStyle = '#fff';
        this.previewCtx.lineWidth = 0.5;
        this.previewCtx.translate(-this.offset.x, -this.offset.y);
        // centerX = p.x + bounds.x + bounds.width / 2;
        // centerY = p.y + bounds.y + bounds.height / 2;
        this.previewCtx.strokeRect(
            Math.round(this.width / 2 - (bounds.width / 2) * this.scale) + 0.5,
            Math.round(this.height / 2 - (bounds.height / 2) * this.scale) + 0.5,
            Math.round(bounds.width * this.scale),
            Math.round(bounds.height * this.scale)
        );
        this.previewCtx.restore();
    }

    getElement (): HTMLElement {
        return this.rootEl;
    }

    getInputsElement (): HTMLElement {
        return this.inputsRootEl;
    }

    getValues (): TViewportParams {
        return {
            x: this.text.x,
            y: this.text.y,
            angleRad: this.rotationSlider.getValue(),
        };
    }

    setText (text: Omit<TRenderTextParam, 'x' | 'y' | 'angleRad'>): void {
        const x = this. text.x;
        const y = this. text.y;
        const angleRad = this. text.angleRad;
        this.text = {
            ...text,
            x,
            y,
            angleRad,
        };
        this.render();
    }

    setSize (width: number, height: number): void {
        if (width === this.width && height === this.height) {
            return;
        }

        this.width = width;
        this.height = height;

        this.targetCanvas.width = this.width;
        this.targetCanvas.height = this.height;

        this.layersCanvas.width = this.width;
        this.layersCanvas.height = this.height;

        this.previewCanvas.width = this.width;
        this.previewCanvas.height = this.height;

        this.previewWrapper.style.width = this.width + 'px';

        this.render();
    }

    destroy (): void {
        BB.destroyEl(this.rootEl);
        BB.destroyEl(this.inputsRootEl);
        BB.destroyEl(this.previewWrapper);
        clearInterval(this.interval);

        this.rotationSlider.destroy();
        BB.destroyEl(this.zoomInBtn);
        BB.destroyEl(this.zoomOutBtn);
        this.eventCapture.remove();
        this.previewPointerListener.destroy();
        this.keyListener.destroy();
        theme.removeIsDarkListener(this.onDarkChange);
    }
}


code:
declare class ClipboardItem {
    constructor(data: { [mimeType: string]: Blob });
}


code:
import {BB} from '../../../bb/bb';
import {KL} from '../../kl';
import {BrowserStorageUi} from '../components/browser-storage-ui';
import {IKlProject, TDropOption, TExportType} from '../../kl-types';
import {SaveReminder} from '../components/save-reminder';
import {ProjectStore} from '../../storage/project-store';
import {LANG} from '../../../language/language';
import newImageImg from '/src/app/img/ui/new-image.svg';
import exportImg from '/src/app/img/ui/export.svg';
import shareImg from '/src/app/img/ui/share.svg';
import uploadImg from '/src/app/img/ui/upload.svg';
import importImg from '/src/app/img/ui/import.svg';
import copyImg from '/src/app/img/ui/copy.svg';

export class FileTab {

    private readonly rootEl: HTMLDivElement;
    private importButton: undefined | HTMLInputElement;
    private fileBrowserStorage: undefined | BrowserStorageUi;

    constructor (
        klRootEl: HTMLElement,
        projectStore: ProjectStore,
        getProject: () => IKlProject,
        private exportType: TExportType,
        onExportTypeChange: (type: TExportType) => void,
        onFileSelect: (fileList: FileList, optionStr: TDropOption) => void,
        onSaveImageToComputer: () => void,
        onNewImage: () => void,
        onShareImage: (callback: () => void) => void,
        onUpload: () => void,
        onCopyToClipboard: () => void,
        saveReminder: SaveReminder,
    ) {
        this.rootEl = document.createElement('div');

        const asyncCreation = (): void => {
            const fileMenu = document.createElement('div');
            const newButton = document.createElement('button');
            const saveButton = document.createElement('button');
            const shareButton = document.createElement('button');
            const uploadImgurButton = document.createElement('button');
            const clipboardButton = document.createElement('button');

            newButton.style.cssFloat = 'left';
            BB.css(saveButton, {
                cssFloat: 'left',
                clear: 'both',
                flexGrow: '1',
            });
            BB.css(clipboardButton, {
                cssFloat: 'left',
                clear: 'both',
            });

            newButton.tabIndex = -1;
            saveButton.tabIndex = -1;
            shareButton.tabIndex = -1;
            uploadImgurButton.tabIndex = -1;
            clipboardButton.tabIndex = -1;

            newButton.innerHTML = `<img class="dark-no-invert" src='${newImageImg}' alt='icon' height='20'/>${LANG('file-new')}`;
            saveButton.innerHTML = `<img src='${exportImg}' alt='icon' height='20'/>${LANG('file-save')}`;
            shareButton.innerHTML = `<img src='${shareImg}' alt='icon' height='20'/>${LANG('file-share')}`;
            uploadImgurButton.innerHTML = `<img style='float:left' src='${uploadImg}' height='20' alt='icon'/>${LANG('file-upload')}`;
            clipboardButton.innerHTML = `<img src='${copyImg}' alt='icon' height='20'/>${LANG('file-copy')}`;
            clipboardButton.title = LANG('file-copy-title');
            newButton.className = 'grid-button';
            saveButton.className = 'grid-button';
            shareButton.className = 'grid-button';
            uploadImgurButton.className = 'grid-button';
            clipboardButton.className = 'grid-button';

            const importWrapper = BB.el({
                className: 'grid-button',
                css: {
                    position: 'relative',
                    cursor: 'pointer',
                    cssFloat: 'left',
                },
            });
            const innerMask = BB.el({
                parent: importWrapper,
                css: {
                    width: '120px',
                    height: '28px',
                    overflow: 'hidden',
                    cursor: 'pointer',
                    position: 'relative',
                },
            });
            this.importButton = BB.el({
                tagName: 'input',
                parent: innerMask,
                css: {
                    display: 'none',
                },
            });
            this.importButton.tabIndex = -1;
            this.importButton.type = 'file';
            this.importButton.multiple = true;
            this.importButton.accept = 'image';
            this.importButton.size = 71;
            this.importButton.onchange = () => {
                if (!this.importButton) {
                    return;
                }
                this.importButton.files && onFileSelect(this.importButton.files, 'default');
                this.importButton.value = '';
            };

            const importFakeButton = BB.el({
                tagName: 'button',
                parent: importWrapper,
                content: "<img class=\"dark-no-invert\" style='float:left' height='20' src='" + importImg + "' alt='icon'/>" + LANG('file-import'),
                css: {
                    width: '120px',
                    display: 'box',
                    position: 'absolute',
                    left: '0',
                    top: '0',
                    cursor: 'pointer',
                },
            });
            importFakeButton.tabIndex = -1;
            importFakeButton.onclick = () => this.importButton && this.importButton.click();

            // --- export filetype dropdown ---
            const exportTypeSelect = new KL.Select({
                optionArr: [
                    ['png', 'PNG'],
                    ['psd', 'PSD'],
                    ['layers', LANG('layers') + ' (PNG)'],
                ],
                initValue: this.exportType,
                onChange: (val) => {
                    this.exportType = val as TExportType;
                    onExportTypeChange(this.exportType);
                    onSaveImageToComputer();
                },
                title: LANG('file-format'),
            });
            BB.css(exportTypeSelect.getElement(), {
                width: '50%',
                height: '30px',
                marginTop: '10px',
                marginLeft: '10px',
            });

            newButton.onclick = onNewImage;
            saveButton.onclick = () => {
                onSaveImageToComputer();
            };
            shareButton.onclick = () => {
                shareButton.disabled = true;
                onShareImage(() => {
                    shareButton.disabled = false;
                });
            };
            uploadImgurButton.onclick = () => onUpload();

            clipboardButton.onclick = () => {
                clipboardButton.blur();
                onCopyToClipboard();
            };

            const saveNote = BB.el({
                className: 'kl-toolspace-note',
                textContent: LANG('file-no-autosave'),
                css: {
                    margin: '10px 10px 0 10px',
                },
            });

            const createSpacer = () => {
                const el = document.createElement('div');
                const clearer = document.createElement('div');
                const line = BB.el({
                    className: 'grid-hr',
                });
                el.append(clearer, line);
                BB.css(clearer, {
                    clear: 'both',
                });
                return el;
            };

            this.fileBrowserStorage = new BrowserStorageUi(projectStore, getProject, saveReminder, klRootEl);
            BB.css(this.fileBrowserStorage.getElement(), {
                //background: 'red',
                margin: '10px',
            });

            const saveRow = BB.el({
                content: [saveButton, exportTypeSelect.getElement()],
                className: 'kl-file-save-row',
            });

            //actual structure
            BB.append(fileMenu, [
                saveNote,
                newButton,
                importWrapper,
                BB.el({css: {clear: 'both'}}),

                saveRow,

                clipboardButton,
                BB.canShareFiles() ? shareButton : undefined,
                BB.el({css: {clear: 'both'}}),

                createSpacer(),
                this.fileBrowserStorage.getElement(),
                createSpacer(),
                uploadImgurButton,
            ]);

            this.rootEl.append(fileMenu);
        };

        setTimeout(asyncCreation, 1);
    }

    refresh (): void {
    }

    getElement (): HTMLElement {
        return this.rootEl;
    }

    setIsVisible (isVisible: boolean): void {
        if (isVisible) {
            this.refresh();
        }
    }

    triggerImport (): void {
        this.importButton && this.importButton.click();
    }
}


code:
import {BB} from '../../../bb/bb';
import {KlSlider} from '../components/kl-slider';
import {Select} from '../components/select';
import {Checkbox} from '../components/checkbox';
import {LANG} from '../../../language/language';
import {TFillSampling} from '../../kl-types';
import {KlColorSlider} from '../components/kl-color-slider';

/**
 * Paint Bucket tab contents (color slider, opacity slider, etc)
 */
export class FillUi {

    private readonly rootEl: HTMLElement;
    private isVisible: boolean;
    private readonly colorDiv: HTMLElement;
    private readonly colorSlider: KlColorSlider;
    private readonly toleranceSlider: KlSlider;
    private readonly opacitySlider: KlSlider;
    private readonly modeSelect: Select<string>;
    private readonly growSelect: Select<string>;
    private isContiguous: boolean;
    private readonly eraserToggle: Checkbox;

    // ---- public ----

    constructor (
        p: {
            colorSlider: KlColorSlider; // when opening tab, inserts it (snatches it from where else it was)
        }
    ) {
        this.rootEl = BB.el({
            css: {
                margin: '10px',
            },
        });
        this.isVisible = true;

        this.colorDiv = BB.el({
            parent: this.rootEl,
            css: {
                marginBottom: '10px',
            },
        });

        this.colorSlider = p.colorSlider;

        this.opacitySlider = new KlSlider({
            label: LANG('opacity'),
            width: 250,
            height: 30,
            min: 1 / 100,
            max: 1,
            value: 1,
            toValue: (displayValue) => displayValue / 100,
            toDisplayValue: (value) => value * 100,
        });
        this.rootEl.append(this.opacitySlider.getElement());

        this.toleranceSlider = new KlSlider({
            label: LANG('bucket-tolerance'),
            width: 250,
            height: 30,
            min: 0,
            max: 255,
            value: 20 * (255 / 100),
            toValue: (displayValue) => displayValue * (255 / 100),
            toDisplayValue: (value) => value / (255 / 100),
        });
        BB.css(this.toleranceSlider.getElement(), {
            marginTop: '10px',
        });
        this.rootEl.append(this.toleranceSlider.getElement());

        const selectRow = BB.el({
            parent: this.rootEl,
            css: {
                display: 'flex',
                marginTop: '10px',
            },
        });

        const modeWrapper = BB.el({
            content: LANG('bucket-sample') + ' ',
            title: LANG('bucket-sample-title'),
            css: {
                fontSize: '15px',
            },
        });
        this.modeSelect = new Select({
            optionArr: [
                ['all', LANG('bucket-sample-all')],
                ['current', LANG('bucket-sample-active')],
                ['above', LANG('bucket-sample-above')],
            ],
            initValue: 'all',
        });
        const modePointerListener = new BB.PointerListener({
            target: this.modeSelect.getElement(),
            onWheel: (e) => {
                this.modeSelect.setDeltaValue(e.deltaY);
            },
        });
        modeWrapper.append(this.modeSelect.getElement());
        selectRow.append(modeWrapper);

        const growWrapper = BB.el({
            content: LANG('bucket-grow') + ' ',
            title: LANG('bucket-grow-title'),
            css: {
                fontSize: '15px',
                marginLeft: '10px',
            },
        });
        this.growSelect = new Select({
            optionArr: [
                ['0', '0'],
                ['1', '1'],
                ['2', '2'],
                ['3', '3'],
                ['4', '4'],
                ['5', '5'],
                ['6', '6'],
                ['7', '7'],
            ],
            initValue: '0',
        });
        const growPointerListener = new BB.PointerListener({
            target: this.growSelect.getElement(),
            onWheel: (e) => {
                this.growSelect.setDeltaValue(e.deltaY);
            },
        });
        growWrapper.append(this.growSelect.getElement());
        selectRow.append(growWrapper);

        this.isContiguous = true;
        const contiguousToggle = new Checkbox({
            init: true,
            label: LANG('bucket-contiguous'),
            title: LANG('bucket-contiguous-title'),
            callback: (b) => {
                this.isContiguous = b;
            },
            css: {
                paddingRight: '5px',
                display: 'inline-block',
                width: '50%',
            },
        });

        this.eraserToggle = new Checkbox({
            init: false,
            label: LANG('eraser'),
            css: {

                paddingRight: '5px',
                display: 'inline-block',
                width: '50%',
            },
        });

        this.rootEl.append(BB.el({
            content: [
                contiguousToggle.getElement(),
                this.eraserToggle.getElement(),
            ],
            css: {
                display: 'flex',
                marginTop: '10px',
            },
        }));
    }

    getElement (): HTMLElement {
        return this.rootEl;
    }

    setIsVisible (pIsVisible: boolean): void {
        this.isVisible = !!pIsVisible;
        this.rootEl.style.display = this.isVisible ? 'block' : 'none';
        if (this.isVisible) {
            this.colorDiv.append(this.colorSlider.getElement(), this.colorSlider.getOutputElement());
        }
    }

    /**
     * [0, 1]
     */
    getTolerance (): number {
        return this.toleranceSlider.getValue();
    }

    getOpacity (): number {
        return this.opacitySlider.getValue();
    }

    getSample (): TFillSampling {
        return this.modeSelect.getValue() as TFillSampling;
    }

    getGrow (): number {
        return parseInt(this.growSelect.getValue(), 10);
    }

    getContiguous (): boolean {
        return this.isContiguous;
    }

    getIsEraser (): boolean {
        return this.eraserToggle.getValue();
    }

}


code:
import {BB} from '../../../bb/bb';
import {KL} from '../../kl';
import {klHistory} from '../../history/kl-history';
import {IKeyString} from '../../../bb/bb-types';
import {StatusOverlay} from '../components/status-overlay';
import {KlCanvasWorkspace} from '../../canvas-ui/kl-canvas-workspace';
import {KlCanvas} from '../../canvas/kl-canvas';
import {LANG} from '../../../language/language';
import {IFilterApply, IFilterGetDialogParam, IFilterGetDialogResult} from '../../kl-types';
import {KlColorSlider} from '../components/kl-color-slider';
import {LayerManager} from './layer-manager/layer-manager';
import {HandUi} from './hand-ui';
import {RGB} from '../../../bb/color/color';
import {getSharedFx} from '../../../fx-canvas/shared-fx';

export class FilterTab {

    private readonly rootEl: HTMLDivElement;
    private isInit = false;

    private testHasWebGL (): boolean {
        return !!getSharedFx();
    }

    constructor (
        private klRootEl: HTMLElement,
        private klColorSlider: KlColorSlider,
        private layerManager: LayerManager,
        private klCanvasWorkspace: KlCanvasWorkspace,
        private handUi: HandUi,
        private getCurrentColor: () => RGB,
        private getKlMaxCanvasSize: () => number,
        private getKlCanvas: () => KlCanvas,
        private getCurrentLayerCtx: () => CanvasRenderingContext2D | null,
        private isEmbed: boolean,
        private statusOverlay: StatusOverlay,
    ) {
        this.rootEl = document.createElement('div');
    }

    private init (): void {
        const filters = KL.filterLib;
        const buttons = [];

        if (!KL.filterLibStatus.isLoaded) {
            throw new Error('filters not loaded');
        }

        const hasWebGL: boolean = this.testHasWebGL();

        if (!hasWebGL) {
            const note = BB.el({
                parent: this.rootEl,
                className: 'kl-toolspace-note',
                content: 'Features disabled because WebGL is failing.',
                css: {
                    margin: '10px',
                    marginBottom: '0',
                },
            });
            const noteButton = BB.el({
                parent: note,
                tagName: 'button',
                textContent: 'Learn More',
                css: {
                    marginLeft: '5px',
                },
            });
            noteButton.onclick = () => {
                KL.popup({
                    target: this.klRootEl,
                    message: 'WebGL is not working',
                    div: BB.el({
                        content: `
See if your browser supports WebGL and has it enabled: get.webgl.org

Recently (2023-05) a number of Chrome users on Chrome OS reported that WebGL fails, although it is enabled & supported.
This has been reported to Google.
`,
                    }),
                    buttons: ['Ok'],
                    clickOnEnter: 'Ok',
                });
            };
        }

        const createButton = (filterKey: string): HTMLElement => {
            const filter = filters[filterKey];

            const button = document.createElement('button');
            const buttonLabel = LANG(filter.lang.button);
            const imClass = filter.darkNoInvert ? 'class="dark-no-invert"' : '';
            const im = '';
            button.innerHTML = im + buttonLabel;
            button.className = 'grid-button';
            BB.css(button, {
                lineHeight: '20px',
                fontSize: '12px',
            });
            button.tabIndex = -1;

            const filterName = LANG(filter.lang.name);

            let isEnabled = true;
            if (filter.webGL && !hasWebGL) {
                isEnabled = false;
            }

            if (isEnabled) {
                button.onclick = () => {

                    type TOptions = 'Ok' | 'Cancel';
                    const dialogButtons: TOptions[] = ['Ok', 'Cancel'];

                    const finishedDialog = (result: TOptions, filterDialog: IFilterGetDialogResult<any>): void => {
                        if (result == 'Cancel') {
                            if (filterDialog.destroy) {
                                filterDialog.destroy();
                            }
                            return;
                        }
                        let input;
                        try {
                            input = filterDialog.getInput!(); // also destroys
                        } catch (e) {
                            if ((e as Error).message.indexOf('.getInput is not a function') !== -1) {
                                throw 'filterDialog.getInput is not a function, filter: ' + filterName;
                            } else {
                                throw e;
                            }
                        }
                        applyFilter(input);
                    };

                    if (!('apply' in filters[filterKey])) {
                        alert('Application not fully loaded');
                        return;
                    }

                    const applyFilter = (input: any) => {
                        const filterResult = filters[filterKey].apply!({
                            context: this.getCurrentLayerCtx(),
                            klCanvas: this.getKlCanvas(),
                            history: klHistory,
                            input: input,
                        } as IFilterApply);
                        if (filterResult === false) {
                            alert("Couldn't apply the edit action");
                        }
                        if (filters[filterKey].updatePos === true) {
                            this.klCanvasWorkspace.resetOrFitView();
                            this.handUi.update(this.klCanvasWorkspace.getScale(), this.klCanvasWorkspace.getAngleDeg());
                        }
                        this.layerManager.update();
                    };

                    if (filters[filterKey].isInstant){
                        button.blur();
                        applyFilter(null);
                        this.statusOverlay.out('"' + filterName + '" ' + LANG('filter-applied'), true);
                    } else {
                        const secondaryColorRGB = this.klColorSlider.getSecondaryRGB();
                        const filterDialog = filters[filterKey].getDialog!({
                            context: this.getCurrentLayerCtx(),
                            klCanvas: this.getKlCanvas(),
                            maxWidth: this.getKlMaxCanvasSize(),
                            maxHeight: this.getKlMaxCanvasSize(),
                            currentColorRgb: {r: this.getCurrentColor().r, g: this.getCurrentColor().g, b: this.getCurrentColor().b},
                            secondaryColorRgb: {r: secondaryColorRGB.r, g: secondaryColorRGB.g, b: secondaryColorRGB.b},
                        } as IFilterGetDialogParam) as IFilterGetDialogResult;

                        if (!filterDialog) {
                            return;
                            //alert('Error: could not perform action');
                            //throw('filter['+filterKey+'].getDialog returned '+filterDialog+'. ctx:' + currentLayerCtx + ' klCanvas:' + klCanvas);
                        }

                        let closeFunc: () => void;
                        // Todo should move into getDialogParams
                        filterDialog.errorCallback = (e) => {
                            setTimeout(() => {
                                alert('Error: could not perform action');
                                throw e;
                            }, 0);
                            closeFunc();
                        };

                        const style: IKeyString = {};
                        if ('width' in filterDialog) {
                            style.width = filterDialog.width + 'px';
                        }

                        KL.popup({
                            target: this.klRootEl,
                            message: '' + filterName + '',
                            div: filterDialog.element,
                            style: style,
                            buttons: dialogButtons,
                            clickOnEnter: 'Ok',
                            callback: (result) => {
                                finishedDialog(result as TOptions, filterDialog);
                            },
                            closeFunc: (func) => {
                                closeFunc = func;
                            },
                        });
                    }
                };
            } else {
                button.disabled = true;
            }

            buttons.push(button);
            return button;
        };

        const addGroup = (groupArr: string[]): void => {
            Object.entries(filters).forEach(([filterKey, filter]) => {
                if (!groupArr.includes(filterKey)) {
                    return;
                }
                if (this.isEmbed && !filter.inEmbed) {
                    return;
                }
                this.rootEl.append(createButton(filterKey));
            });
        };

        const groupA = [
            'cropExtend',
            'flip',
            'perspective',
            'resize',
            'rotate',
            'transform',
        ];
        const groupB = [
            'brightnessContrast',
            'curves',
            'distort',
            'hueSaturation',
            'invert',
            'tiltShift',
            'toAlpha',
            'blur',
            'unsharpMask',
        ];
        const groupC = [
            'grid',
            'noise',
            'pattern',
            'vanishPoint',
        ];

        addGroup(groupA);
        this.rootEl.append(BB.el({className: 'grid-hr'}));
        addGroup(groupB);
        this.rootEl.append(BB.el({className: 'grid-hr'}));
        addGroup(groupC);

        this.isInit = true;
    }

    getElement (): HTMLElement {
        return this.rootEl;
    }

    show (): void {
        if (!this.isInit) {
            this.init();
        }
        this.rootEl.style.display = 'block';
    }

    hide (): void {
        this.rootEl.style.display = 'none';
    }

}


code:
import {BB} from '../../../bb/bb';
import {KlSlider} from '../components/kl-slider';
import {LANG} from '../../../language/language';
import {Checkbox} from '../components/checkbox';
import {Options} from '../components/options';
import {TGradientType} from '../../kl-types';
import {KlColorSlider} from '../components/kl-color-slider';

interface IGradientUiSettings {
    opacity: number;
    type: TGradientType;
    doLockAlpha: boolean;
    doSnap: boolean;
    isReversed: boolean;
    isEraser: boolean;
}

/**
 * Gradient Tool tab contents
 *
 * @param p
 * @constructor
 */
export class GradientUi {

    private readonly rootEl: HTMLElement;
    private colorDiv: HTMLElement;
    private colorSlider: KlColorSlider;
    private isVisible: boolean;
    private readonly iconArr: HTMLElement[];

    private settings: IGradientUiSettings = {
        opacity: 1,
        type: 'linear',
        doLockAlpha: false,
        doSnap: false,
        isReversed: false,
        isEraser: false,
    };

    private updateIcons (): void {
        const col1 = this.settings.isReversed ? '#0000' : '#000';
        const col2 = this.settings.isReversed ? '#000' : '#0000';
        this.iconArr[0].style.background = `linear-gradient(${col1}, ${col2})`;
        this.iconArr[1].style.background = `linear-gradient(${col2}, ${col1}, ${col2})`;
        this.iconArr[2].style.background = `radial-gradient(${col1}, ${col2})`;
    }

    // ----- public -------

    constructor (
        p: {
            colorSlider: KlColorSlider; // when opening tab, inserts it (snatches it from where else it was)
        }
    ) {
        this.colorSlider = p.colorSlider;
        this.rootEl = BB.el({
            css: {
                margin: '10px',
            },
        });
        this.isVisible = true;

        this.colorDiv = BB.el({
            parent: this.rootEl,
            css: {
                marginBottom: '10px',
            },
        });

        this.iconArr = [];
        {
            const size = 33;
            [0, 1, 2].forEach(() => {
                const el = BB.el({
                    className: 'dark-invert',
                    css: {
                        width: size + 'px',
                        height: size + 'px',
                        borderRadius: '3px',
                        margin: '1px',
                    },
                });
                this.iconArr.push(el);
            });
        }
        this.updateIcons();

        const typeOptions = new Options<TGradientType>({
            optionArr: [
                {
                    id: 'linear',
                    label: this.iconArr[0],
                    title: LANG('gradient-linear'),
                },
                {
                    id: 'linear-mirror',
                    label: this.iconArr[1],
                    title: LANG('gradient-linear-mirror'),
                },
                {
                    id: 'radial',
                    label: this.iconArr[2],
                    title: LANG('gradient-radial'),
                },
            ],
            initId: 'linear',
            onChange: (id) => {
                this.settings.type = id;
            },
        });
        this.rootEl.append(typeOptions.getElement());

        const opacitySlider = new KlSlider({
            label: LANG('opacity'),
            width: 250,
            height: 30,
            min: 1 / 100,
            max: 1,
            value: this.settings.opacity,
            toValue: (displayValue) => displayValue / 100,
            toDisplayValue: (value) => value * 100,
            onChange: (val) => {
                this.settings.opacity = val;
            },
        });
        BB.css(opacitySlider.getElement(), {
            marginTop: '10px',
        });
        this.rootEl.append(opacitySlider.getElement());

        const row1 = BB.el({
            parent: this.rootEl,
            css: {
                display: 'flex',
                alignItems: 'center',
                marginTop: '10px',
            },
        });

        const reverseToggle = new Checkbox({
            init: false,
            label: LANG('reverse'),
            callback: (b) => {
                this.settings.isReversed = b;
                this.updateIcons();
            },
            css: {
                width: '50%',
            },
        });

        const doSnapToggle = new Checkbox({
            init: false,
            label: LANG('angle-snap'),
            title: LANG('angle-snap-title'),
            callback: (b) => {
                this.settings.doSnap = b;
            },
            css: {
                width: '50%',
            },
        });

        row1.append(
            reverseToggle.getElement(),
            doSnapToggle.getElement(),
        );

        const row2 = BB.el({
            parent: this.rootEl,
            css: {
                display: 'flex',
                alignItems: 'center',
                marginTop: '10px',
            },
        });

        const eraserToggle = new Checkbox({
            init: this.settings.isEraser,
            label: LANG('eraser'),
            callback: (b) => {
                this.settings.isEraser = b;
            },
            css: {
                width: '50%',
            },
        });

        const lockAlphaToggle = new Checkbox({
            init: false,
            label: LANG('lock-alpha'),
            title: LANG('lock-alpha-title'),
            callback: (b) => {
                this.settings.doLockAlpha = b;
            },
            doHighlight: true,
            css: {
                width: '50%',
            },
        });

        row2.append(
            eraserToggle.getElement(),
            lockAlphaToggle.getElement(),
        );

    }

    getElement (): HTMLElement {
        return this.rootEl;
    }

    setIsVisible (isVisible: boolean): void {
        this.isVisible = !!isVisible;
        this.rootEl.style.display = isVisible ? 'block' : 'none';
        if (isVisible) {
            this.colorDiv.append(this.colorSlider.getElement(), this.colorSlider.getOutputElement());
        }
    }

    getSettings (): IGradientUiSettings {
        return BB.copyObj(this.settings);
    }

}


code:
import {BB} from '../../../bb/bb';
import angleImg from '/src/app/img/ui/angle.svg';
import rotateImg from '/src/app/img/ui/edit-rotate.svg';
import {LANG} from '../../../language/language';

/**
 * Ui, when hand tool tab is open.
 */
export class HandUi {

    private readonly rootEl: HTMLElement;
    private readonly onAngleChange: (angleDeg: number, isRelative?: boolean) => void;
    private isVisible: boolean = true;
    private scale: number;
    private angleDeg: number;
    private readonly scaleEl: HTMLElement;
    private readonly angleEl: HTMLElement;
    private readonly angleIm: HTMLImageElement;

    private updateUi (): void {
        this.scaleEl.innerHTML = Math.round(this.scale * 100) + '%';
        this.angleEl.innerHTML =  Math.round(this.angleDeg) + '°';

        this.angleIm.style.transform = 'rotate(' + this.angleDeg + 'deg)';
        if (this.angleDeg % 90 === 0) {
            this.angleIm.style.boxShadow = 'inset 0 0 0 1px rgba(255,255,255, 1), 0 0 0 1px rgba(0, 0, 0, 0.3)';
        } else {
            this.angleIm.style.boxShadow = '';
        }
    }

    // ---- public ----

    constructor (
        p: {
            scale: number; // initial value
            angleDeg: number; // initial value
            onReset: () => void;
            onFit: () => void;
            onAngleChange: (angleDeg: number, isRelative?: boolean) => void;
        }
    ) {
        this.rootEl = BB.el({
            css: {
                margin: '10px',
            },
        });
        this.scale = p.scale;
        this.angleDeg = p.angleDeg;
        this.onAngleChange = p.onAngleChange;

        const row1 = BB.el({
            css: {
                marginBottom: '10px',
                display: 'flex',
            },
        });
        const row2 = BB.el({
            css: {
                display: 'flex',
                marginBottom: '10px',
            },
        });
        const row3 = BB.el({
            css: {
                display: 'flex',
            },
        });
        this.rootEl.append(row1, row2, row3);

        this.scaleEl = BB.el({
            css: {
                width: '55px',
                userSelect: 'none',
            },
        });
        row1.append(this.scaleEl);

        this.angleIm = new Image();
        this.angleIm.src = angleImg;
        BB.css(this.angleIm, {
            verticalAlign: 'bottom',
            width: '20px',
            height: '20px',
            marginRight: '5px',
            borderRadius: '10px',
            background: 'rgba(0,0,0,0.2)',
            userSelect: 'none',
        });
        row1.append(this.angleIm);

        this.angleEl = BB.el({
            parent: row1,
            css: {
                userSelect: 'none',
            },
        });

        this.updateUi();

        const resetButton = BB.el({
            tagName: 'button',
            content: LANG('hand-reset'),
            onClick: p.onReset,
        });
        BB.makeUnfocusable(resetButton);

        const fitButton = BB.el({
            tagName: 'button',
            content: LANG('hand-fit'),
            css: {
                marginLeft: '10px',
            },
            onClick: p.onFit,
        });
        BB.makeUnfocusable(fitButton);
        row2.append(resetButton, fitButton);

        const leftRotateButton = BB.el({
            tagName: 'button',
            content: '',
            onClick: function () {
                p.onAngleChange(-15, true);
            },
        });
        BB.makeUnfocusable(leftRotateButton);

        const resetAngleButton = BB.el({
            tagName: 'button',
            content: '0°',
            css: {
                marginLeft: '10px',
            },
            onClick: function () {
                p.onAngleChange(0);
            },
        });
        BB.makeUnfocusable(resetAngleButton);

        const rightRotateButton = BB.el({
            tagName: 'button',
            content: '',
            css: {
                marginLeft: '10px',
            },
            onClick: function () {
                p.onAngleChange(15, true);
            },
        });
        BB.makeUnfocusable(rightRotateButton);
        row3.append(leftRotateButton, resetAngleButton, rightRotateButton);

    }

    getElement (): HTMLElement {
        return this.rootEl;
    }

    setIsVisible (pIsVisible: boolean): void {
        this.isVisible = !!pIsVisible;
        this.rootEl.style.display = this.isVisible ? 'block' : 'none';
        if (this.isVisible) {
            this.updateUi();
        }
    }

    update (pScale: number, pAngleDeg: number): void {
        this.scale = pScale;
        this.angleDeg = pAngleDeg;
        if (this.isVisible) {
            this.updateUi();
        }
    }
}