code:
import {BB} from '../../../bb/bb';
import {HexColorDialog} from '../modals/color-slider-hex-dialog';
import {calcSliderFalloffFactor} from './slider-falloff';
import eyedropperImg from '/src/app/img/ui/tool-picker.svg';
import {LANG} from '../../../language/language';
import {IRGB} from '../../kl-types';
import {HSV, RGB} from '../../../bb/color/color';
import {ERASE_COLOR} from '../../brushes/erase-color';

/**
 * big main HS+V color slider
 * 2 elements: slider, and colorpreview(output) + eyedropper
 */
export class KlColorSlider {

    private readonly rootEl: HTMLElement;
    private readonly outputDiv: HTMLElement;
    private readonly divPreview: HTMLElement;
    private readonly hexButton: HTMLElement;
    private readonly secondaryColorBtn: HTMLElement;
    private primaryColorRGB: RGB;
    private primaryColorHSV: HSV;
    private secondaryColorRGB: RGB;
    private secondaryColorHSV: HSV;
    private readonly controlH: HTMLElement;
    private pickCallback: undefined | ((b: boolean) => void);
    private isPicking: boolean;
    private readonly pickerButton: HTMLElement;
    private readonly width: number;
    private readonly height: number; // hue slider and output height
    private readonly hueStop: Element;

    private readonly SVContainer: HTMLElement;
    private readonly pointerSV: HTMLElement;
    private svHeight: number;
    private readonly svSvg: HTMLElement;

    private readonly emitColor: (rgb: RGB) => void;

    private updatePrimaryHSV (hsv: HSV): void {
        if (hsv.s === 0) {
            this.primaryColorHSV = new BB.HSV(this.primaryColorHSV.h, hsv.s, hsv.v);
        } else {
            this.primaryColorHSV = new BB.HSV(hsv.h, hsv.s, hsv.v);
        }
    }

    private updateSVCanvas (): void {
        const rgb = BB.ColorConverter.toRGB(new BB.HSV(this.primaryColorHSV.h, 100, 100));
        BB.setAttributes(this.hueStop, {
            'stop-color': '#' + BB.ColorConverter.toHexString(rgb),
        });
    }

    private updateSVPointer (): void {
        const left = this.primaryColorHSV.s / 100 * this.width - 7;
        const top = (1 - this.primaryColorHSV.v / 100) * this.svHeight - 6;
        BB.css(this.pointerSV, {
            left: left + 'px',
            top: top + 'px',
        });
    }

    private setColPreview (): void {
        this.divPreview.style.backgroundColor = 'rgb(' + this.primaryColorRGB.r + ',' + this.primaryColorRGB.g + ',' + this.primaryColorRGB.b + ')';

        if (BB.testIsWhiteBestContrast(this.primaryColorRGB)) {
            BB.css(this.pickerButton, {
                filter: 'invert(1)',
            });
            BB.css(this.hexButton, {
                filter: 'invert(1)',
            });
        } else{
            BB.css(this.pickerButton, {
                filter: '',
            });
            BB.css(this.hexButton, {
                filter: '',
            });
        }

    }

    private updateSecondaryColor (): void {
        this.secondaryColorBtn.style.backgroundColor = BB.ColorConverter.toRgbStr(this.secondaryColorRGB);
    }

    // ---- public ----

    constructor (
        p: {
            width: number;
            height: number; // hue slider and output height
            svHeight: number;
            startValue: IRGB; // 0-255
            onPick: (rgb: IRGB) => void;
        }
    ) {
        this.rootEl = BB.el({
            className: 'kl-color-picker',
            css: {
                position: 'relative',
            },
        });
        this.outputDiv = BB.el({
            css: {
                display: 'flex',
                alignItems: 'center',
            },
        });
        this.width = p.width;
        this.svHeight = p.svHeight;
        this.height = p.height;
        this.emitColor = p.onPick;

        this.primaryColorRGB = {
            r: parseInt('' + p.startValue.r),
            g: parseInt('' + p.startValue.g),
            b: parseInt('' + p.startValue.b),
        };
        this.primaryColorHSV = BB.ColorConverter.toHSV(p.startValue); // BB.HSV
        this.secondaryColorRGB = {r: ERASE_COLOR, g: ERASE_COLOR, b: ERASE_COLOR};
        this.secondaryColorHSV = BB.ColorConverter._RGBtoHSV(this.secondaryColorRGB); // BB.HSV

        const svWrapper = BB.el();
        this.svSvg = new DOMParser().parseFromString('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="none"><defs><linearGradient id="value" gradientTransform="rotate(90)"><stop offset="0" stop-color="#fff"/><stop offset="100%" stop-color="#000"/></linearGradient><linearGradient id="hue" gradientTransform="rotate(0)"><stop offset="0" stop-color="#fff"/><stop id="hue-stop" offset="100%" stop-color="#f00"/></linearGradient></defs><rect x="0" y="0" width="100" height="100" fill="url(\'#hue\')"/><rect x="0" y="0" width="100" height="100" fill="url(\'#value\')" style="mix-blend-mode: multiply"/></svg>', 'image/svg+xml').documentElement;
        {
            const hueStop = this.svSvg.querySelector('#hue-stop');
            if (!hueStop) {
                throw Error('#hue-stop not found in svSvg');
            }
            this.hueStop = hueStop;
        }
        BB.setAttributes(this.hueStop, {
            'stop-color': '#f0f',
        });
        BB.css(this.svSvg, {
            width: this.width + 'px',
            height: this.svHeight + 'px',
        });
        svWrapper.append(this.svSvg);

        const divH = BB.el({
            className: 'kl-color-picker__h',
            css: {
                overflow: 'hidden',
                position: 'relative',
                width: this.width + 'px',
                height: this.height + 'px',
                cursor: 'ew-resize',
                marginTop: '1px',
                marginBottom: '1px',
            },
        });
        this.divPreview = BB.el({
            className: 'kl-color-picker__preview',
            css: {
                display: 'flex',
                justifyContent: 'space-between',
                width: (this.height * 2.5) + 'px',
                height: this.height + 'px',
            },
        });
        this.controlH = BB.el();

        const createHueBg = (targetEl: HTMLElement) => {
            const im = new Image();
            BB.css(im, {
                position: 'absolute',
                left: '0',
                top: '0',
                display: 'none',
                pointerEvents: 'none',
            });
            const cv = BB.canvas(this.width, this.height);
            const ctx = BB.ctx(cv);
            const gradH = ctx.createLinearGradient(0, 0, this.width, 0);
            for (let i = 0; i < 1; i += 0.01) {
                const col = BB.ColorConverter.toRGB(new BB.HSV(i * 360, 100, 100));
                let ha = (parseInt('' + col.r)).toString(16);
                let hb = (parseInt('' + col.g)).toString(16);
                let hc = (parseInt('' + col.b)).toString(16);
                if (ha.length === 1) {
                    ha = '0' + ha;
                }
                if (hb.length === 1) {
                    hb = '0' + hb;
                }
                if (hc.length === 1) {
                    hc = '0' + hc;
                }
                gradH.addColorStop(i, '#' + ha + hb + hc);
            }
            ctx.fillStyle = gradH;
            ctx.fillRect(0, 0, this.width, this.height);

            targetEl.append(im);
            im.alt = 'hue';
            im.src = cv.toDataURL('image/png');
            im.style.display = 'block';
        };

        this.updateSVCanvas();
        this.rootEl.style.width = this.width + 'px';
        this.rootEl.oncontextmenu = () => {
            return false;
        };

        this.SVContainer = BB.el({
            className: 'kl-color-picker__sv',
            css: {
                width: this.width + 'px',
                height: this.svHeight + 'px',
                overflow: 'hidden',
                display: 'block',
                position: 'relative',
                cursor: 'crosshair',
            },
        });

        this.pointerSV = BB.el({
           css: {
               width: '12px',
               height: '12px',
               borderRadius: '6px',
               position: 'absolute',
               pointerEvents: 'none',
               boxShadow: '0px 0px 0 1px #000, inset 0px 0px 0 1px #fff',
           },
        });

        this.SVContainer.append(svWrapper, this.pointerSV);
        this.updateSVPointer();
        this.rootEl.append(this.SVContainer, divH);
        this.outputDiv.append(this.divPreview);

        //divH.className = "svSlider";
        BB.css(this.controlH, {
            width: '1px',
            height: this.height + 'px',
            background: '#000',
            borderLeft: '1px solid #fff',
            position: 'absolute',
            top: '0',
            left: parseInt('' + (this.primaryColorHSV.h / 360 * this.width - 1)) + 'px',
        });

        const virtualHSV = {
            h: 0,
            s: 0,
            v: 0,
        };

        this.pickerButton = BB.el({
            title: LANG('eyedropper') + ' [Alt]',
            className: 'color-picker-preview-button',
            css: {
                width: '30px',
                height: '30px',
                backgroundImage: 'url(' + eyedropperImg + ')',
                backgroundRepeat:  'no-repeat',
                backgroundSize: '70%',
                backgroundPosition: 'center',
            },
            onClick: () => {
                if (this.isPicking) {
                    this.pickCallback && this.pickCallback(false);
                    this.pickingDone();
                } else {
                    this.pickerButton.classList.remove('color-picker-preview-button-hover');
                    this.pickerButton.classList.add('color-picker-preview-button-active');
                    this.isPicking = true;
                    this.pickCallback && this.pickCallback(true);
                }
            },
        });
        this.isPicking = false;

        const pickerButtonPointerListener = new BB.PointerListener({
            target: this.pickerButton,
            onEnterLeave: (isOver) => {
                if (this.isPicking) {
                    return;
                }
                this.pickerButton.classList.toggle('color-picker-preview-button-hover', isOver);
            },
        });
        this.divPreview.append(this.pickerButton);

        this.hexButton = BB.el({
            content: '#',
            className: 'color-picker-preview-button',
            title: LANG('manual-color-input'),
            css: {
                height: '100%',
                width: this.height + 'px',
                lineHeight: this.height + 'px',
                fontSize: (this.height * 0.65) + 'px',
            },
            onClick: () => {
                new HexColorDialog({
                    color: new BB.RGB(this.primaryColorRGB.r, this.primaryColorRGB.g, this.primaryColorRGB.b),
                    onClose: (rgbObj) => {
                        if (!rgbObj) {
                            return;
                        }
                        this.setColor(rgbObj);
                        this.emitColor(new BB.RGB(this.primaryColorRGB.r, this.primaryColorRGB.g, this.primaryColorRGB.b));
                    },
                });
            },
        });
        const hexButtonPointerListener = new BB.PointerListener({
            target: this.hexButton,
            onEnterLeave: (isOver) => {
                this.hexButton.classList.toggle('color-picker-preview-button-hover', isOver);
            },
        });
        this.divPreview.append(this.hexButton);

        this.setColPreview();

        setTimeout(() => {
            createHueBg(divH);
            divH.append(this.controlH);
            const svPointerListener = new BB.PointerListener({
                target: svWrapper,
                fixScribble: true,
                onPointer: (event) => {
                    if (event.type === 'pointerdown') {

                        // prevent manual slider input keeping focus on iPad
                        BB.unfocusAnyInput();

                        this.SVContainer.classList.toggle('kl-color-picker--active', true);

                        if (event.button === 'left') {

                            virtualHSV.s = event.relX / this.width * 100;
                            virtualHSV.v = 100 - event.relY / this.svHeight * 100;

                            this.primaryColorHSV = new BB.HSV(this.primaryColorHSV.h, virtualHSV.s, virtualHSV.v);
                            this.primaryColorRGB = BB.ColorConverter.toRGB(this.primaryColorHSV);

                            this.updateSVPointer();
                            this.setColPreview();
                            this.emitColor(BB.ColorConverter.toRGB(this.primaryColorHSV));
                        } else {
                            virtualHSV.s = this.primaryColorHSV.s;
                            virtualHSV.v = this.primaryColorHSV.v;
                        }
                    }

                    if (event.type === 'pointermove' && ['left', 'right'].includes(event.button || '')) {

                        let factor = 1;
                        if (event.button === 'right') {
                            factor = 0.5;
                        }

                        virtualHSV.s += event.dX / this.width * 100 * factor;
                        virtualHSV.v -= event.dY / this.svHeight * 100 * factor;

                        this.primaryColorHSV = new BB.HSV(this.primaryColorHSV.h, virtualHSV.s, virtualHSV.v);
                        this.primaryColorRGB = BB.ColorConverter.toRGB(this.primaryColorHSV);
                        this.updateSVPointer();
                        this.setColPreview();
                        this.emitColor(BB.ColorConverter.toRGB(this.primaryColorHSV));

                    }

                    if (event.type === 'pointerup') {
                        this.SVContainer.classList.toggle('kl-color-picker--active', false);
                    }

                },
            });
            const hPointerListener = new BB.PointerListener({
                target: divH,
                fixScribble: true,
                onPointer: (event) => {

                    if (event.type === 'pointerdown') {

                        // prevent manual slider input keeping focus on iPad
                        BB.unfocusAnyInput();

                        divH.classList.toggle('kl-color-picker--active', true);

                        if (event.button === 'left') {

                            virtualHSV.h = event.relX / this.width * 359.99;

                            this.primaryColorHSV = new BB.HSV(virtualHSV.h, this.primaryColorHSV.s, this.primaryColorHSV.v);
                            this.primaryColorRGB = BB.ColorConverter.toRGB(this.primaryColorHSV);
                            this.controlH.style.left = (Math.round(this.primaryColorHSV.h / 359.99 * this.width) - 1) + 'px';
                            this.updateSVCanvas();
                            this.setColPreview();
                            this.emitColor(BB.ColorConverter.toRGB(this.primaryColorHSV));
                        } else {
                            virtualHSV.h = this.primaryColorHSV.h;
                        }
                    }

                    if (event.type === 'pointermove' && ['left', 'right'].includes(event.button || '')) {

                        const deltaY = Math.abs(event.pageY - event.downPageY!);
                        const factor = calcSliderFalloffFactor(deltaY, event.button === 'right');

                        virtualHSV.h += event.dX / this.width * 359.99 * factor;

                        if (event.button === 'right') {
                            virtualHSV.h = virtualHSV.h % 359.99;
                            if (virtualHSV.h < 0) {
                                virtualHSV.h += 359.99;
                            }
                        }
                        virtualHSV.h = Math.min(359.99, virtualHSV.h);
                        this.primaryColorHSV = new BB.HSV(virtualHSV.h, this.primaryColorHSV.s, this.primaryColorHSV.v);
                        this.primaryColorRGB = BB.ColorConverter.toRGB(this.primaryColorHSV);
                        this.controlH.style.left = (Math.round(this.primaryColorHSV.h / 359.99 * this.width) - 1) + 'px';
                        this.updateSVCanvas();
                        this.setColPreview();
                        this.emitColor(BB.ColorConverter.toRGB(this.primaryColorHSV));

                    }

                    if (event.type === 'pointerup') {
                        divH.classList.toggle('kl-color-picker--active', false);
                    }
                },
            });
        }, 1);

        // --- secondary color ---

        this.secondaryColorBtn = BB.el({
            parent: this.outputDiv,
            title: LANG('secondary-color') + ' [X]',
            className: 'kl-color-picker__secondary',
            css: {
                cursor: 'pointer',
                marginLeft: '5px',
                width: '22px',
                height: '22px',
            },
            onClick: (e) => {
                e.preventDefault();
                this.swapColors();
            },
        });
        this.updateSecondaryColor();
    }

    setColor (c: IRGB): void {
        this.primaryColorRGB = {r: parseInt('' + c.r), g: parseInt('' + c.g), b: parseInt('' + c.b)};
        this.updatePrimaryHSV(BB.ColorConverter.toHSV(c));
        this.controlH.style.left = parseInt('' + (this.primaryColorHSV.h / 359 * this.width - 1)) + 'px';
        this.updateSVCanvas();
        this.updateSVPointer();
        this.setColPreview();
    }

    getColor (): RGB {
        return new BB.RGB(this.primaryColorRGB.r, this.primaryColorRGB.g, this.primaryColorRGB.b);
    }

    getSecondaryRGB (): RGB {
        return new BB.RGB(this.secondaryColorRGB.r, this.secondaryColorRGB.g, this.secondaryColorRGB.b);
    }

    setPickCallback (func: (b: boolean) => void): void {
        this.pickCallback = func;
    }

    pickingDone (): void {
        if (!this.isPicking) {
            return;
        }
        this.isPicking = false;
        this.pickerButton.classList.remove('color-picker-preview-button-active');
    }

    enable (e: boolean): void {
        if (e) {
            this.rootEl.style.pointerEvents = '';
            this.rootEl.style.opacity = '1';
            this.outputDiv.style.pointerEvents = '';
            this.outputDiv.style.opacity = '1';
        } else {
            this.rootEl.style.pointerEvents = 'none';
            this.rootEl.style.opacity = '0.5';
            this.outputDiv.style.pointerEvents = 'none';
            this.outputDiv.style.opacity = '0.5';
        }
    }

    setHeight (h: number): void {
        h = parseInt('' +(h - this.height * 2 - 3), 10);
        if (h === this.svHeight) {
            return;
        }
        this.svHeight = h;
        BB.css(this.svSvg, {
            width: this.width + 'px',
            height: this.svHeight + 'px',
        });
        this.SVContainer.style.height = this.svHeight + 'px';
        this.updateSVPointer();
    }

    swapColors (): void {
        // swap hsv
        let tmp: RGB | HSV = this.secondaryColorHSV;
        this.secondaryColorHSV = this.primaryColorHSV;
        this.updatePrimaryHSV(tmp);
        // swap rgb
        tmp = this.secondaryColorRGB;
        this.secondaryColorRGB = this.primaryColorRGB;
        this.primaryColorRGB = tmp;

        this.controlH.style.left = parseInt('' + (this.primaryColorHSV.h / 359 * this.width - 1)) + 'px';
        this.updateSVCanvas();
        this.updateSVPointer();
        this.setColPreview();
        this.updateSecondaryColor();

        this.emitColor(new BB.RGB(this.primaryColorRGB.r, this.primaryColorRGB.g, this.primaryColorRGB.b));
    }

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

    getOutputElement (): HTMLElement {
        return this.outputDiv;
    }

}


code:
import {BB} from '../../../bb/bb';

type TDropOption = 'default' | 'layer' | 'image';

/**
 * Not really generalized. UI when you drag/drop an image into window.
 * The moment you create it, it will listen.
 * */
export class KlImageDropper {

    private readonly rootEl: HTMLElement;

    // ---- public ----
    constructor (
        p: {
            onDrop: (files: FileList, optionStr: TDropOption) => void;
            target: HTMLElement;
            enabledTest: () => boolean;
        }
    ) {
        //set up DOM
        this.rootEl = BB.el({
            content: 'Drop to import',
            css: {
                paddingTop: '100px',
                position: 'fixed',
                left: '0',
                top: '0',
                width: '100%',
                height: '100%',
                background: 'rgba(50, 50, 50, 0.7)',
                color: '#fff',
                textShadow: '2px 2px #000',
                textAlign: 'center',
                fontSize: '25px',
            },
        });
        const wrapperEl = BB.el({
            css: {
                'marginTop': '50px',
                'display': 'flex',
                'justifyContent': 'center',
            },
        });
        const optionStyle = {
            width: '200px',
            padding: '50px',
            margin: '10px',
            //opacity: 0.5,
            borderRadius: '20px',
            border: '1px dashed #fff',
            background: '#00aefe',
            fontWeight: 'bold',
        };
        const optionLayerEl = BB.el({
            content: 'As Layer',
            css: optionStyle,
        });
        const optionImageEl = BB.el({
            content: 'As New Image',
            css: optionStyle,
        });

        this.rootEl.append(wrapperEl);
        wrapperEl.append(optionLayerEl, optionImageEl);

        let rootCounter = 0;
        let optionLayerCounter = 0;
        let optionImageCounter = 0;

        const destroy = () => {
            rootCounter = 0;
            optionLayerCounter = 0;
            optionImageCounter = 0;
            this.rootEl.remove();
        };

        /**
         * check, can drop content be imported
         */
        const testAcceptType = (event: DragEvent): boolean => {
            return !!event.dataTransfer && !event.dataTransfer.types.includes('text/plain');
        };

        const updateOptions = () => {
            const boxShadow = '0 0 20px 4px #fff';
            if (optionLayerCounter > 0) {
                optionLayerEl.style.boxShadow = boxShadow;
                optionImageEl.style.boxShadow = '';
            } else if (optionImageCounter > 0) {
                optionLayerEl.style.boxShadow = '';
                optionImageEl.style.boxShadow = boxShadow;
            } else {
                optionLayerEl.style.boxShadow = '';
                optionImageEl.style.boxShadow = '';
            }
        };

        optionLayerEl.addEventListener('dragenter', () => {
            optionLayerCounter++;
            updateOptions();
        });
        optionLayerEl.addEventListener('dragleave', () => {
            optionLayerCounter--;
            updateOptions();
        });
        optionImageEl.addEventListener('dragenter', () => {
            optionImageCounter++;
            updateOptions();
        });
        optionImageEl.addEventListener('dragleave', () => {
            optionImageCounter--;
            updateOptions();
        });

        const rootDragOver = (event: DragEvent): void => {
            if (!testAcceptType(event)) {
                return;
            }
            event.stopPropagation();
            event.preventDefault();
        };

        const rootDragEnter = (event: DragEvent): void => {
            if (!p.enabledTest() || !testAcceptType(event)) {
                return;
            }
            if (rootCounter === 0) {
                p.target.append(this.rootEl);
            }
            rootCounter++;
        };

        const rootDragLeave = (event: DragEvent): void => {
            if (!testAcceptType(event) || rootCounter === 0) {
                return;
            }
            rootCounter = Math.max(0, rootCounter - 1);
            if (rootCounter === 0) {
                this.rootEl.remove();
            }
        };

        const rootDrop = (event: DragEvent): void => {
            if (!testAcceptType(event) || !event.dataTransfer || event.dataTransfer.files.length === 0) {
                destroy();
                return;
            }
            event.stopPropagation();
            event.preventDefault();

            let optionStr: TDropOption = 'default';
            if (optionLayerCounter > 0) {
                optionStr = 'layer';
            } else if (optionImageCounter > 0) {
                optionStr = 'image';
            }

            p.onDrop(event.dataTransfer.files, optionStr);

            if (rootCounter > 0) {
                this.rootEl.remove();
            }
            rootCounter = 0;
            optionLayerCounter = 0;
            optionImageCounter = 0;
            updateOptions();
        };

        window.addEventListener('dragover', rootDragOver, false);
        window.addEventListener('dragenter', rootDragEnter, false);
        window.addEventListener('dragleave', rootDragLeave, false);
        window.addEventListener('drop', rootDrop, false);

        // if something goes wrong and you're stuck with overlay
        this.rootEl.addEventListener('pointerdown', () => {
            destroy();
        });
        const keyListener = new BB.KeyListener({
            onDown: (keyStr) => {
                if (rootCounter > 0 && keyStr === 'esc') {
                    destroy();
                }
            },
        });
    }

    // ---- interface ----
}

repo_name: Doggermelon/klecks

license: mit


code:
import {BB} from '../../../bb/bb';
import {input} from './input';

/**
 * Used by KlSlider. Allows user to type in value in input (type=number) field, instead of dragging with mouse.
 * Input goes away when losing focus, or when pressing Enter/Escape.
 */
export class KlSliderManualInput {

    private readonly input: HTMLInputElement;
    private isEnabled: boolean = true;
    private scrollBefore: { x: number; y: number } | undefined; // window scroll position on creation
    private lastValue: number; // last emitted value
    private isClosed: boolean = false;

    private emit (): void {
        if (this.lastValue !== Number(this.input.value)) {
            this.onChange(Number(this.input.value));
            this.lastValue = Number(this.input.value);
        }
    }

    private privateOnClose (): void {
        if (this.isClosed) {
            return;
        }
        this.isClosed = true;

        this.emit();
        this.onClose();
        setTimeout(() => {
            // because iPad keyboard changes the scroll position
            this.scrollBefore && window.scrollTo(this.scrollBefore.x, this.scrollBefore.y);
            this.scrollBefore = undefined;
        });
    }

    // --- public ---

    constructor (
        value: number, // initial value (displayValue)
        min: number,
        max: number,
        rect: DOMRect, // size of input field
        private onChange: (displayValue: number) => void,
        private onClose: () => void,
        roundDigits?: number,
    ) {

        this.input = input({
            type: 'number',
            init: value,
            min,
            max,
            callback: (val) => {
                this.emit();
            },
        });
        if (roundDigits !== 0) {
            this.input.setAttribute('step', 'any');
        }
        this.input.onblur = () => {
            this.privateOnClose();
        };
        this.input.addEventListener('keyup', (e) => {
            if (['Enter', 'Escape'].includes(e.key)) {
                this.privateOnClose();
            } else {
                this.emit();
            }
        });
        this.input.addEventListener('wheel', () => this.emit());

        this.scrollBefore = {
            x: window.scrollX,
            y: window.scrollY,
        };

        let currentValue;
        if (roundDigits || roundDigits === 0) {
            currentValue = BB.round(value, roundDigits);
        } else {
            currentValue = value;
        }
        this.lastValue = currentValue;
        this.input.value = '' + currentValue;

        BB.css(this.input, {
            width: rect.width + 'px',
            height: rect.height + 'px',
        });
    }

    getElement () {
        return this.input;
    }

    setIsEnabled (b: boolean): void {
        this.isEnabled = !!b;
    }

    focus (): void {
        this.input.focus();
        this.input.select();
    }

    destroy (): void {
        BB.destroyEl(this.input);
    }
}


code:
import {BB} from '../../../bb/bb';
import {calcSliderFalloffFactor} from './slider-falloff';
import {IKlSliderConfig} from '../../kl-types';
import {languageStrings} from '../../../language/language';
import {KlSliderManualInput} from './kl-slider-manual-input';
import {SplineInterpolator} from '../../../bb/math/line';
import {PointerListener} from '../../../bb/input/pointer-listener';
import {IPointerEvent} from '../../../bb/input/event.types';
import {IChainElement} from '../../../bb/input/event-chain/event-chain.types';

/**
 * Horizontal slider, can be changed by dragging anywhere on it. Has a label & value.
 * e.g. used for brush size
 *
 * left mouse button - set absolute value
 * right mouse button - change relative value
 * drag mouse vertically away - precision mode
 * double-click/tap slider - manual input mode
 *
 * Values can be spline interpolated
 * On change callback can be debounced
 *
 */
export class KlSlider {

    /*
    Three types of values
    - value - the actual value, when using setValue(), getValue(), and what is emitted
    - displayValue - the number that the user sees on the slider and in the manual input field
        - might be further formatted with formatFunc
        - also might be the same as value, if no toDisplayValue function provided
    - sliderValue - the draggable position of the slider, 0 - 1
     */

    private value: number;

    private readonly elementWidth: number;
    private readonly elementHeight: number;
    private isEnabled: boolean;

    private min: number; // min value
    private max: number; // max value
    private useSpline: boolean;
    private splineInterpolator: undefined | SplineInterpolator;
    private readonly resolution: number;

    private readonly isChangeOnFinal: boolean;
    private readonly formatFunc: undefined | ((val: number) => number | string);
    private readonly unit: string | undefined;

    private readonly onChange: (val: number) => void;
    private readonly valueToDisplayValue: (value: number) => number;
    private readonly displayValueToValue: (displayValue: number) => number;

    private readonly rootEl: HTMLElement;
    private readonly sliderWrapperEl: HTMLElement;
    private readonly textEl: HTMLElement; // displays label, displayValue
    private readonly label: string;
    private readonly control: HTMLElement;
    private manualInput: undefined | KlSliderManualInput;
    private readonly manualInputRoundDigits: number | undefined;

    private pointerListener: PointerListener | undefined;
    private readonly pointerListenerTimeout: ReturnType<typeof setTimeout>;

    private readonly eventResMs: undefined | number;
    private emitValue: undefined | number; // next value for interval
    private emitInterval: (ReturnType<typeof setTimeout> | undefined); // interval of eventResMs which calls onChange()

    private valueToSliderValue (value: number): number {
        if (this.useSpline && this.splineInterpolator) {
            return this.splineInterpolator.findX(value, Math.floor(this.resolution)) || 0;
        }
        return (value - this.min) / (this.max - this.min);
    }

    private sliderValueToValue (sliderValue: number): number {
        let result = this.min + sliderValue * (this.max - this.min);
        if (this.useSpline && this.splineInterpolator) {
            result = this.splineInterpolator.interpolate(sliderValue) || 0;
        }
        return result;
    }

    private updateLabel (): void {
        let displayValue: number | string = this.valueToDisplayValue(this.value);
        displayValue = this.formatFunc ? this.formatFunc(displayValue) : Math.round(displayValue);
        displayValue = displayValue.toLocaleString(languageStrings.getCode());
        const unit = this.unit !== undefined ? this.unit : '';
        this.textEl.innerHTML = this.label + '  ' + displayValue + unit + '';

        const sliderValue = this.valueToSliderValue(this.value);
        this.control.style.width = (sliderValue * this.elementWidth) + 'px';
    }

    private emit (isFinal: boolean): void {
        if (!isFinal && this.isChangeOnFinal) {
            return;
        }

        if (isFinal || !this.eventResMs) {
            this.onChange(this.value);
            if (this.emitInterval) {
                clearInterval(this.emitInterval);
                this.emitInterval = undefined;
            }
            return;
        }

        if (this.emitInterval) {
            this.emitValue = this.value;

        } else {
            this.onChange(this.value);

            this.emitInterval = setInterval(() => {
                if (this.emitValue === undefined) {
                    clearInterval(this.emitInterval);
                    this.emitInterval = undefined;
                } else {
                    this.onChange(this.emitValue);
                    this.emitValue = undefined;
                }
            }, this.eventResMs);
        }
    }

    private updateEnable (): void {
        this.sliderWrapperEl.classList.toggle('slider-wrapper--disabled', !this.isEnabled);
        BB.css(this.sliderWrapperEl, {
            opacity: this.isEnabled ? '' : '0.5',
            pointerEvents: this.isEnabled ? '' : 'none',
        });
        if (this.manualInput) {
            this.manualInput.setIsEnabled(this.isEnabled);
        }
    }

    private showManualInput (): void {

        this.manualInput = new KlSliderManualInput(
            this.valueToDisplayValue(this.value),
            this.valueToDisplayValue(this.min),
            this.valueToDisplayValue(this.max),
            this.sliderWrapperEl.getBoundingClientRect(),
            (val) => {
                val = this.displayValueToValue(val);
                this.setValue(BB.clamp(val, this.min, this.max));
                this.onChange(this.value);
            },
            () => {
                BB.css(this.sliderWrapperEl, {
                    display: '',
                });
                if (this.manualInput) {
                    this.manualInput.getElement().remove();
                    this.manualInput.destroy();
                }
                this.manualInput = undefined;
            },
            (this.manualInputRoundDigits && this.manualInputRoundDigits > 0) ? this.manualInputRoundDigits : 0,
        );
        this.manualInput.setIsEnabled(this.isEnabled);
        this.rootEl.append(this.manualInput.getElement());
        setTimeout(() => {
            this.manualInput && this.manualInput.focus();
        });
        BB.css(this.sliderWrapperEl, {
            display: 'none',
        });
    }

    // --- public ---

    constructor (
        p: {
            label: string;
            width: number; // px
            height: number; // px
            min: number; // min value
            max: number;
            value: number;
            resolution?: number; // int, if you want spline.findX() to use a custom resolution
            curve?: 'quadratic' | [number, number][]; // optional. array is BB.SplineInterpolator points. 0-1
            formatFunc?: (val: number) => number | string; // function to display a different number than value
            onChange?: (val: number) => void;
            isChangeOnFinal?: boolean; // only fire onChange on pointerUp
            eventResMs?: number; // frequency of change events
            isEnabled?: boolean; // default = true
            manualInputRoundDigits?: number; // default 0, how value should be rounded for manual input
            toDisplayValue?: (value: number) => number;
            toValue?: (displayValue: number) => number;
            unit?: string; // attached to end of value in output
        }
    ) {
        this.isEnabled = p.isEnabled !== false;
        this.manualInputRoundDigits = p.manualInputRoundDigits;
        this.useSpline = !!p.curve;

        if (!p.label) {
            throw new Error('KlSlider missing params');
        }
        if (p.min != 0 && p.max != 0 && p.value != 0) {
            if (!p.min || !p.max || !p.value) {
                throw new Error('KlSlider broken params');
            }
        }
        if (p.min >= p.max) {
            throw new Error('KlSlider broken params');
        }

        this.min = p.min;
        this.max = p.max;
        this.value = BB.clamp(p.value, this.min, this.max);
        this.elementWidth = p.width;
        this.elementHeight = p.height;
        this.resolution = p.resolution ? p.resolution : this.elementWidth * 2;

        this.onChange = p.onChange ? p.onChange : () => {};
        if (!p.toValue !== !p.toDisplayValue) {
            throw new Error('both or neither have to be set, toValue and toDisplayValue');
        }
        this.displayValueToValue = p.toValue ? p.toValue : (displayValue) => displayValue;
        this.valueToDisplayValue = p.toDisplayValue ? p.toDisplayValue : (value) => value;
        this.isChangeOnFinal = !!p.isChangeOnFinal;
        this.formatFunc = p.formatFunc;
        this.eventResMs = p.eventResMs;
        this.unit = p.unit;

        if (this.useSpline) {
            if (!p.curve) {
                throw new Error('curve needs to be set if useSpline true');
            }
            const curveArr: [number, number][] = p.curve === 'quadratic' ? BB.quadraticSplineInput(this.min, this.max, 0.1) : p.curve;
            this.splineInterpolator = new BB.SplineInterpolator(curveArr);
        }

        this.rootEl = BB.el({
            css: {
                display: 'flex',
            },
        });
        this.sliderWrapperEl = BB.el({
            parent: this.rootEl,
            className: 'slider-wrapper',
            css: {
                overflow: 'hidden',
                position: 'relative',
                width: this.elementWidth + 'px',
                height: this.elementHeight + 'px',
                userSelect: 'none',
            },
        });
        this.rootEl.oncontextmenu = function () {
            return false;
        };
        this.label = p.label;
        const labelFontSize = this.elementHeight - 14;
        this.textEl = BB.el({
            content: this.label,
            css: {
                position: 'absolute',
                display: 'flex',
                alignItems: 'center',
                marginLeft: '7px',
                height: '100%',
                fontSize: labelFontSize + 'px',
                pointerEvents: 'none',
            },
        });
        this.control = BB.el({
            className: 'slider-inner',
            css: {
                position: 'absolute',
                left: '0',
                top: '0',
                width: (this.valueToSliderValue(this.value) * this.elementWidth) + 'px',
                height: this.elementHeight + 'px',
            },
        });
        const controlInner = document.createElement('div');

        this.sliderWrapperEl.append(this.control, this.textEl);
        this.control.append(controlInner);

        this.updateEnable();

        const doubleTapper = new BB.DoubleTapper({
            onDoubleTap: () => {
                this.showManualInput();
            },
        });
        doubleTapper.setAllowedButtonArr(['left', 'right']);
        const eventChain = new BB.EventChain({
            chainArr: [
                doubleTapper as IChainElement,
            ],
        });

        let virtualVal: number;
        const onPointer = (event: IPointerEvent) => {
            event.eventPreventDefault();

            if (!this.isEnabled) {
                return;
            }

            if (event.type === 'pointerdown') {

                // unfocus manual slider input
                BB.unfocusAnyInput();

                this.sliderWrapperEl.className = 'slider-wrapper slider-wrapper--active';

                if (event.button === 'left') {
                    let sliderValue = event.relX / this.elementWidth;
                    sliderValue = Math.max(0, Math.min(1, sliderValue));
                    this.value = this.sliderValueToValue(sliderValue);
                    this.updateLabel();
                    this.emit(false);
                }
                virtualVal = this.valueToSliderValue(this.value);
            }

            if (event.type === 'pointermove' && ['left', 'right'].includes(event.button || '')) {

                let deltaX = event.dX;
                const deltaY = Math.abs(event.pageY - (event.downPageY || 0));
                const factor = calcSliderFalloffFactor(deltaY, event.button === 'right');
                deltaX *= factor;
                deltaX /= this.elementWidth;

                virtualVal += deltaX;

                const sliderValue = Math.max(0, Math.min(1, virtualVal));
                this.value = this.sliderValueToValue(sliderValue);
                this.updateLabel();
                this.emit(false);
            }

            if (event.type === 'pointerup') {
                this.sliderWrapperEl.className = 'slider-wrapper';
                this.emit(true);
            }

        };

        this.pointerListenerTimeout = setTimeout(() => {
            this.pointerListener = new BB.PointerListener({
                target: this.sliderWrapperEl,
                fixScribble: true,
                onPointer: (e) => {
                    onPointer(e);
                    eventChain.chainIn(e);
                },
                onWheel: (event) => {
                    let sliderValue = this.valueToSliderValue(this.value);
                    sliderValue = BB.clamp(sliderValue - event.deltaY / 40, 0, 1);
                    this.value = this.sliderValueToValue(sliderValue);
                    this.updateLabel();
                    this.onChange(this.value);
                },
            });
            this.updateLabel();
        }, 1);
    }

    changeSliderValue (f: number): void {
        if (!this.isEnabled) {
            return;
        }
        let sliderValue = this.valueToSliderValue(this.value);
        sliderValue = BB.clamp(sliderValue + f, 0, 1);
        this.value = this.sliderValueToValue(sliderValue);
        this.updateLabel();
        this.onChange(this.value);
    }

    setValue (v: number): void {
        this.value = BB.clamp(v, this.min, this.max);
        this.updateLabel();
    }

    getValue (): number {
        return this.value;
    }

    update (config: IKlSliderConfig): void {
        this.min = config.min;
        this.max = config.max;
        this.useSpline = !!config.curve;
        if (this.useSpline) {
            if (!config.curve) {
                throw new Error('curve needs to be set if useSpline true');
            }
            const curveArr = config.curve === 'quadratic' ? BB.quadraticSplineInput(this.min, this.max, 0.1) : config.curve;
            this.splineInterpolator = new BB.SplineInterpolator(curveArr);
        } else {
            this.splineInterpolator = undefined;
        }
        this.setIsEnabled(!config.isDisabled);
    }

    setIsEnabled (e: boolean): void {
        this.isEnabled = !!e;
        this.updateEnable();
    }

    destroy (): void {
        clearTimeout(this.pointerListenerTimeout);
        this.pointerListener && this.pointerListener.destroy();
        if (this.manualInput) {
            this.manualInput.destroy();
        }
        if (this.emitInterval) {
            clearInterval(this.emitInterval);
        }
    }

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

}


code:
import {BB} from '../../../bb/bb';
import {klHistory} from '../../history/kl-history';
import {LANG} from '../../../language/language';
import {TKlCanvasLayer, TUiLayout} from '../../kl-types';
import {ISize2D} from '../../../bb/bb-types';
import {theme} from '../../../theme/theme';
import {KlCanvasLayer} from '../../canvas/kl-canvas';
import {throwIfNull} from '../../../bb/base/base';

/**
 * Previews currently active layer
 * thumbnail (hover shows bigger preview), layername, opacity
 *
 * internally listens to kl history. updates when there's a change.
 * but you need to update it when the active layer changed. (different canvas object)
 *
 * update visibility for performance
 */
export class LayerPreview {

    private readonly rootEl: HTMLElement;
    private readonly contentWrapperEl: HTMLElement;
    private readonly height: number;
    private layerObj: TKlCanvasLayer | undefined;
    private isVisible: boolean;
    private uiState: TUiLayout;

    private readonly checkEl: HTMLInputElement;

    private readonly nameLabelEl: HTMLElement;
    private readonly opacityEl: HTMLElement;
    private lastDrawnSize: ISize2D;
    private lastDrawnState: number; // from KlHistory

    private readonly canvasSize: number;
    private readonly canvas: HTMLCanvasElement;
    private readonly canvasCtx: CanvasRenderingContext2D;

    private animationCount: number; // >0 means it's animating
    private readonly animationLength: number;

    private readonly animationCanvas: HTMLCanvasElement;
    private readonly animationCanvasCtx: CanvasRenderingContext2D;
    private animationCanvasCheckerPattern: CanvasPattern = {} as CanvasPattern;

    private largeCanvasIsVisible: boolean;
    private readonly largeCanvasWrapper: HTMLElement;
    private readonly largeCanvasSize: number;
    private readonly largeCanvas: HTMLCanvasElement;
    private readonly largeCanvasCtx: CanvasRenderingContext2D;
    private largeCanvasCheckerPattern: CanvasPattern = {} as CanvasPattern;

    private updateCheckerPatterns (): void {
        const checker = BB.createCheckerCanvas(4, theme.isDark());
        this.animationCanvasCheckerPattern = throwIfNull(this.animationCanvasCtx.createPattern(checker, 'repeat'));
        this.largeCanvasCheckerPattern = throwIfNull(this.canvasCtx.createPattern(checker, 'repeat'));
    }

    private animate (): void {
        if (this.animationCount === 0) {
            return;
        }

        this.animationCount--;

        this.canvasCtx.save();
        this.canvasCtx.globalAlpha = Math.pow((this.animationLength - this.animationCount) / this.animationLength, 2);
        this.canvasCtx.drawImage(this.animationCanvas, 0, 0);
        this.canvasCtx.restore();

        if (this.animationCount > 0) {
            requestAnimationFrame(() => this.animate());
        }
    }

    /**
     * is always instant
     */
    private drawLargeCanvas (): void {

        if (!this.largeCanvasIsVisible || !this.layerObj) {
            return;
        }

        const layerCanvas = this.layerObj.context.canvas;

        const canvasDimensions = BB.fitInto(layerCanvas.width, layerCanvas.height, this.largeCanvasSize, this.largeCanvasSize, 1);
        this.largeCanvas.width = Math.round(canvasDimensions.width);
        this.largeCanvas.height = Math.round(canvasDimensions.height);
        this.largeCanvasCtx.save();
        if (this.largeCanvas.width > layerCanvas.width) {
            this.largeCanvasCtx.imageSmoothingEnabled = false;
        } else {
            this.largeCanvasCtx.imageSmoothingEnabled = true;
            this.largeCanvasCtx.imageSmoothingQuality = 'high';
        }
        this.largeCanvasCtx.fillStyle = this.largeCanvasCheckerPattern;
        this.largeCanvasCtx.fillRect(0, 0, this.largeCanvas.width, this.largeCanvas.height);
        this.largeCanvasCtx.drawImage(layerCanvas, 0, 0, this.largeCanvas.width, this.largeCanvas.height);
        this.largeCanvasCtx.restore();

        const bounds = this.rootEl.getBoundingClientRect();
        BB.css(this.largeCanvasWrapper, {
            top: Math.max(10, (bounds.top + this.height / 2 - this.largeCanvas.height / 2)) + 'px',
        });

    }

    private draw (isInstant: boolean): void {
        if (!this.isVisible || !this.layerObj) {
            return;
        }

        const layerIsVisible = this.layerObj.isVisible;
        this.checkEl.parentElement!.title = layerIsVisible ? LANG('layers-active-layer-visible') : LANG('layers-active-layer-hidden');
        this.checkEl.checked = layerIsVisible;
        this.checkEl.style.boxShadow = layerIsVisible ? '' : '0 0 0 1px red';

        this.nameLabelEl.textContent = this.layerObj.name;
        if (this.layerObj.isVisible) {
            this.opacityEl.innerHTML = LANG('opacity') + '
' + Math.round(this.layerObj.opacity * 100) + '%';
        } else {
            this.opacityEl.innerHTML = LANG('opacity') + '
<s>' + Math.round(this.layerObj.opacity * 100) + '%</s>';
        }

        const layerCanvas = this.layerObj.context.canvas;

        if (layerCanvas.width !== this.lastDrawnSize.width || layerCanvas.height !== this.lastDrawnSize.height) {
            const canvasDimensions = BB.fitInto(layerCanvas.width, layerCanvas.height, this.canvasSize, this.canvasSize, 1);
            this.canvas.width = Math.round(canvasDimensions.width);
            this.canvas.height = Math.round(canvasDimensions.height);

            isInstant = true;
        }

        this.animationCanvas.width = this.canvas.width;
        this.animationCanvas.height = this.canvas.height;

        this.animationCanvasCtx.save();
        this.animationCanvasCtx.imageSmoothingEnabled = false;
        this.animationCanvasCtx.fillStyle = this.animationCanvasCheckerPattern;
        this.animationCanvasCtx.fillRect(0, 0, this.animationCanvas.width, this.animationCanvas.height);
        this.animationCanvasCtx.drawImage(layerCanvas, 0, 0, this.animationCanvas.width, this.animationCanvas.height);
        this.animationCanvasCtx.restore();

        if (isInstant) {
            this.animationCount = 0;
            this.canvasCtx.save();
            this.canvasCtx.drawImage(this.animationCanvas, 0, 0);
            this.canvasCtx.restore();

        } else {
            this.animationCount = this.animationLength;
            this.animate();

        }

        this.drawLargeCanvas();

        this.lastDrawnState = klHistory.getState();
        this.lastDrawnSize.width = layerCanvas.width;
        this.lastDrawnSize.height = layerCanvas.height;
    }

    // ---- public ----
    constructor (
        p: {
            onClick: () => void; // when clicking on layer name
            klRootEl: HTMLElement;
            uiState: TUiLayout;
        }
    ) {
        // internally redraws with in an interval. checks history is something changed
        // this update will be animated
        // it will not be animated if the resolution changed
        // also redraws when you call updateLayer - not animated

        // syncs via updateLayer, and internally updates layer opacity via a hack

        this.rootEl = BB.el({
            className: 'kl-layer-preview',
        });
        this.isVisible = true;
        this.height = 40;
        this.canvasSize = this.height - 10;
        this.largeCanvasSize = 300;
        this.lastDrawnState = -2;
        this.lastDrawnSize = {
            width: 0,
            height: 0,
        };
        this.animationCanvas = BB.canvas(); // to help animate the transition
        this.animationCanvasCtx = BB.ctx(this.animationCanvas);
        this.animationLength = 30;
        this.animationCount = 0;
        this.largeCanvasIsVisible = false;
        let largeCanvasAnimationTimeout: ReturnType<typeof setTimeout>;
        const largeCanvasAnimationDurationMs = 300;
        this.uiState = p.uiState;

        // --- setup dom ---
        this.contentWrapperEl = BB.el({
            css: {
                display: 'flex',
                alignItems: 'center',
                height: this.height + 'px',
            },
        });

        const checkWrapper = BB.el({
            css: {
                display: 'flex',
                justifyContent: 'flex-end',
                alignItems: 'center',
                width: '23px',
            },
        });
        this.checkEl = BB.el({
            parent: checkWrapper,
            tagName: 'input',
            css: {
                pointerEvents: 'none',
            },
            custom: {
                type: 'checkbox',
                disabled: 'true',
            },
        });

        const canvasWrapperEl = BB.el({
            css: {
                //background: '#f00',
                minWidth: this.height + 'px',
                height: this.height + 'px',
                display: 'flex',
                justifyContent: 'center',
                alignItems: 'center',
            },
        });
        this.canvas = BB.canvas(this.canvasSize, this.canvasSize);
        this.canvasCtx = BB.ctx(this.canvas);
        this.canvas.title = LANG('layers-active-layer');
        const nameWrapper = BB.el({
            css: {
                //background: '#ff0',
                flexGrow: '1',
                paddingLeft: '10px',
                fontSize: '13px',
                overflow: 'hidden',
                position: 'relative',
            },
        });
        this.nameLabelEl = BB.el({
            content: '',
            css: {
                cssFloat: 'left',
                whiteSpace: 'nowrap',
            },
        });
        const clickableEl = BB.el({
            css: {
                // background: 'rgba(0,255,0,0.6)',
                position: 'absolute',
                left: '10px',
                top: '0',
                width: '90px',
                height: '100%',
            },
        });
        if (p.onClick) {
            clickableEl.addEventListener('click', () => p.onClick());
            this.canvas.addEventListener('click', () => p.onClick());
            checkWrapper.addEventListener('click', () => p.onClick());
        }
        this.opacityEl = BB.el({
            content: LANG('opacity') + '
100%',
            css: {
                minWidth: '60px',
                fontSize: '12px',
                textAlign: 'center',
            },
        });

        this.largeCanvasWrapper = BB.el({
            onClick: BB.handleClick,
            css: {
                pointerEvents: 'none',
                background: '#fff',
                position: 'absolute',
                right: '280px',
                top: '10px',
                border: '1px solid #aaa',
                boxShadow: '1px 1px 3px rgba(0,0,0,0.3)',
                transition: 'opacity '+largeCanvasAnimationDurationMs+'ms ease-in-out',
                userSelect: 'none',
                display: 'block',
                webkitTouchCallout: 'none',
            },
        });
        this.largeCanvas = BB.canvas(this.largeCanvasSize, this.largeCanvasSize);
        this.largeCanvasWrapper.append(this.largeCanvas);
        this.largeCanvasCtx = BB.ctx(this.largeCanvas);
        BB.css(this.largeCanvas, {
            display: 'block',
        });

        canvasWrapperEl.append(this.canvas);
        nameWrapper.append(this.nameLabelEl, clickableEl);
        this.contentWrapperEl.append(checkWrapper, canvasWrapperEl, nameWrapper, this.opacityEl);
        this.rootEl.append(this.contentWrapperEl);

        this.updateCheckerPatterns();
        theme.addIsDarkListener(() => {
            this.updateCheckerPatterns();
            this.draw(true);
        });

        // --- update logic ---

        // cross-fade done via 2 canvases (old and new state)
        // both have checkerboard background drawn on them, both fully opaque
        // -> no "lighter" is needed for accurate cross-fading

        setInterval(() => {

            if (!this.layerObj) {
                return;
            }

            const currentState = klHistory.getState();
            if (currentState === this.lastDrawnState) {
                return;
            }

            //update opacity w hack
            this.layerObj.opacity = (this.layerObj.context.canvas as KlCanvasLayer).opacity;

            this.draw(false);

        }, 2000);

        const removeLargeCanvas = () => {
            this.largeCanvasWrapper.remove();
        };

        const showLargeCanvas = (b: boolean) => {
            if (this.largeCanvasIsVisible === b) {
                return;
            }

            clearTimeout(largeCanvasAnimationTimeout);
            this.largeCanvasIsVisible = b;

            if (b) {
                largeCanvasAnimationTimeout = setTimeout(() => {
                    this.drawLargeCanvas();
                    this.largeCanvasWrapper.style.opacity = '0';
                    p.klRootEl.append(this.largeCanvasWrapper);
                    setTimeout(() => {
                        this.largeCanvasWrapper.style.opacity = '1';
                    }, 20);
                }, 250);

            } else {
                this.largeCanvasWrapper.style.opacity = '0';
                largeCanvasAnimationTimeout = setTimeout(removeLargeCanvas, largeCanvasAnimationDurationMs + 20);
            }

        };

        const pointerListener = new BB.PointerListener({
            target: this.canvas,
            onEnterLeave: (b) => {
                showLargeCanvas(b);
            },
        });
    }

    // ---- interface ----

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

    setIsVisible (b: boolean): void {
        if (this.isVisible === b) {
            return;
        }
        this.isVisible = b;
        this.contentWrapperEl.style.display = this.isVisible ? 'flex' : 'none';
        this.rootEl.style.marginBottom = this.isVisible ? '' : '10px';

        const currentState = klHistory.getState();
        if (b && this.lastDrawnState !== currentState) {
            this.draw(true);
        }
    }

    //when the layer might have changed
    setLayer (klCanvasLayerObj: TKlCanvasLayer): void {
        this.layerObj = klCanvasLayerObj;
        this.draw(true);
    }

    setUiState (stateStr: TUiLayout): void {
        this.uiState = stateStr;

        if (this.uiState === 'left') {
            BB.css(this.largeCanvasWrapper, {
                left: '280px',
                right: '',
            });
        } else {
            BB.css(this.largeCanvasWrapper, {
                left: '',
                right: '280px',
            });
        }
    }

}


code:
import {BB} from '../../../bb/bb';

/**
 * selectable options
 */
export class Options<IdType> {

    private readonly rootEl: HTMLElement;
    private selectedId: IdType;
    private readonly optionArr: {
        id: IdType;
        el: HTMLElement;
    }[];
    private readonly onChange: ((id: IdType) => void) | undefined;

    private getIndex (): number {
        for (let i = 0; i < this.optionArr.length; i++) {
            if (this.optionArr[i].id === this.selectedId) {
                return i;
            }
        }
        return -1;
    }

    private update (): void {
        for (let i = 0; i < this.optionArr.length; i++) {
            this.optionArr[i].el.classList.toggle(
                'kl-option-selected',
                this.optionArr[i].id === this.selectedId
            );
        }
    }

    // ---- public ----
    constructor (
        p: {
            optionArr: {
                id: IdType;
                label: string | HTMLElement | SVGElement;
                title?: string;
            }[];
            initId?: IdType;
            onChange?: (id: IdType) => void;
            changeOnInit?: boolean; // trigger change on creation
            isSmall? :boolean;
        }
    ) {
        this.rootEl = BB.el();

        const wrapperEl = BB.el({
            parent: this.rootEl,
            className: 'kl-option-wrapper',
            css: {
                display: 'flex',
            },
        });

        if (p.onChange) {
            this.onChange = p.onChange;
        }
        this.optionArr = [];
        this.selectedId = ('initialId' in p && p.initId !== undefined) ? p.initId : p.optionArr[0].id;

        const createOption = (o: {
            id: IdType;
            label: string | HTMLElement | SVGElement;
            title?: string;
        }) => {

            const classArr = ['kl-option'];
            if (p.isSmall) {
                classArr.push('kl-option--small');
            }
            if (typeof o.label !== 'string') {
                classArr.push('kl-option--custom-el');
                BB.css(o.label, {
                    display: 'block',
                    pointerEvents: 'none',
                });
            }

            const optionObj = {
                id: o.id,
                el: BB.el({
                    parent: wrapperEl,
                    content: o.label,
                    className: classArr.join(' '),
                    onClick: () => {
                        if (this.selectedId !== optionObj.id) {
                            this.selectedId = optionObj.id;
                            this.update();
                            this.onChange && this.onChange(this.selectedId);
                        }
                    },
                }),
            };

            if (o.title) {
                optionObj.el.title = o.title;
            }

            this.optionArr.push(optionObj);
        };

        for (let i = 0; i < p.optionArr.length; i++) {
            createOption(p.optionArr[i]);
        }

        this.update();

        if (p.changeOnInit) {
            setTimeout(() => {
                this.onChange && this.onChange(this.selectedId);
            }, 0);
        }
    }

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

    getValue (): IdType {
        return this.selectedId;
    }

    next (): void {
        this.selectedId = this.optionArr[(this.getIndex() + 1) % this.optionArr.length].id;
        this.update();
        this.onChange && this.onChange(this.selectedId);
    }

    previous (): void {
        this.selectedId = this.optionArr[(this.optionArr.length + this.getIndex() - 1) % this.optionArr.length].id;
        this.update();
        this.onChange && this.onChange(this.selectedId);
    }

    destroy (): void {
        this.optionArr.forEach(item => {
            BB.destroyEl(item.el);
        });
        this.optionArr.splice(0, this.optionArr.length);
    }
}


code:
import {BB} from '../../../bb/bb';
import {KlColorSliderSmall} from './kl-color-slider-small';
import {KlSlider} from './kl-slider';
import {BrushSettingService, TBrushSettingEmit} from '../../brushes-ui/brush-setting-service';
import {LANG} from '../../../language/language';
import {IRGB} from '../../kl-types';
import {IVector2D} from '../../../bb/bb-types';

/**
 * Compressed HUD toolspace. When you hold ctrl+alt.
 * small color picker, brush settings
 */
export class OverlayToolspace {

    private readonly rootEl: HTMLElement;

    // ---- public ----
    constructor (
        p: {
            brushSettingService: BrushSettingService; // to sync with outside
            enabledTest: () => boolean; // calls to see if it's allowed to show
        }
    ) {
        const sizeObj = {
            width: 150,
            svHeight: 90,
            hHeight: 20,
            sliderHeight: 25,
        };

        let isVisible = false;
        this.rootEl = BB.el({
            className: 'kl-overlay-toolspace',
        });
        const queuedObj: {
            color: IRGB | null;
            size: number | null;
            opacity: number | null;
        } = {
            color: null,
            size: null,
            opacity: null,
        };

        // --- inputs ---

        //color selection
        const colorEl = BB.el({
            parent: this.rootEl,
            className: 'kl-overlay-toolspace__color',
        });
        const colorSlider = new KlColorSliderSmall({
            width: sizeObj.width,
            heightSV: sizeObj.svHeight,
            heightH: sizeObj.hHeight,
            color: p.brushSettingService.getColor(),
            callback: (rgbObj: IRGB) => {
                selectedColorEl.style.backgroundColor = 'rgb(' + rgbObj.r + ',' + rgbObj.g + ',' + rgbObj.b + ')';
                p.brushSettingService.setColor(rgbObj, subscriptionFunc);
            },
        });
        const selectedColorEl = BB.el({
            css: {
                width: sizeObj.width + 'px',
                height: sizeObj.hHeight + 'px',
                pointerEvents: 'none',
            },
        });
        {
            const initialColor = p.brushSettingService.getColor();
            selectedColorEl.style.backgroundColor = 'rgb(' + initialColor.r + ',' + initialColor.g + ',' + initialColor.b + ')';
        }

        colorEl.append(selectedColorEl, colorSlider.getElement());

        const updateColor = (rgbObj: IRGB) => {
            colorSlider.setColor(rgbObj);
            selectedColorEl.style.backgroundColor = 'rgb(' + rgbObj.r + ',' + rgbObj.g + ',' + rgbObj.b + ')';
        };

        //brushsize slider

        const sizeSlider = new KlSlider({
            label: LANG('brush-size'),
            width: sizeObj.width,
            height: sizeObj.sliderHeight,
            min: 0,
            max: 500,
            value: 50,
            resolution: 225,
            eventResMs: 1000 / 30,
            toDisplayValue: (val) => val * 2,
            toValue: (displayValue) => displayValue / 2,
            onChange: (v) => {
                p.brushSettingService.setSize(v);
            },
            formatFunc: (displayValue) => {
                if (displayValue < 10) {
                    return BB.round(displayValue, 1);
                } else {
                    return Math.round(displayValue);
                }
            },
        });
        BB.css(sizeSlider.getElement(), {
            marginTop: '2px',
        });

        const opacitySlider = new KlSlider({
            label: LANG('opacity'),
            width: sizeObj.width,
            height: sizeObj.sliderHeight,
            min: 0,
            max: 1,
            value: 1,
            resolution: 225,
            eventResMs: 1000 / 30,
            toDisplayValue: (val) => val * 100,
            toValue: (displayValue) => displayValue / 100,
            onChange: (v) => {
                p.brushSettingService.setOpacity(v);
            },
        });
        BB.css(opacitySlider.getElement(), {
            margin: '2px 0',
        });
        this.rootEl.append(sizeSlider.getElement(), opacitySlider.getElement());

        // --- general setup ---

        const subscriptionFunc = (event: TBrushSettingEmit) => {
            if (event.type === 'color') {
                if (!isVisible) {
                    queuedObj.color = event.value;
                } else {
                    updateColor(event.value);
                }
            }
            if (event.type === 'size') {
                if (!isVisible) {
                    queuedObj.size = event.value;
                } else {
                    sizeSlider.setValue(event.value);
                }
            }
            if (event.type === 'opacity') {
                if (!isVisible) {
                    queuedObj.opacity = event.value;
                } else {
                    opacitySlider.setValue(event.value);
                }
            }
            if (event.type === 'sliderConfig') {
                sizeSlider.update(event.value.sizeSlider);
                opacitySlider.update(event.value.opacitySlider);
            }
        };
        p.brushSettingService.subscribe(subscriptionFunc);
        {
            const sliderConfig = p.brushSettingService.getSliderConfig();
            sizeSlider.update(sliderConfig.sizeSlider);
            opacitySlider.update(sliderConfig.opacitySlider);
            sizeSlider.setValue(p.brushSettingService.getSize());
            opacitySlider.setValue(p.brushSettingService.getOpacity());
        }

        const updateUI = () => {

            // unfocus manual slider input
            BB.unfocusAnyInput();

            this.rootEl.style.display = isVisible ? 'block' : 'none';
            if (isVisible && mousePos) {
                BB.css(this.rootEl, {
                    left: (mousePos.x - Math.round(sizeObj.width / 2)) + 'px',
                    top: (mousePos.y - Math.round(sizeObj.svHeight + sizeObj.hHeight * 3 / 2)) + 'px',
                });
            }
        };

        let mousePos: IVector2D | null = null;
        document.addEventListener('pointermove', (event) => {
            mousePos = {
                x: event.pageX,
                y: event.pageY,
            };
        });

        const keyListener = new BB.KeyListener({
            onDown: (keyStr, event, comboStr, isRepeat) => {
                if (isRepeat) {
                    return;
                }
                if (isVisible) {
                    isVisible = false;
                    updateUI();
                    return;
                }

                if (!p.enabledTest() || !mousePos) {
                    return;
                }

                if (['ctrl+alt', 'cmd+alt', 'alt+ctrl', 'alt+cmd'].includes(comboStr)) {
                    event.preventDefault();
                    isVisible = true;

                    if (queuedObj.color !== null) {
                        updateColor(queuedObj.color);
                        queuedObj.color = null;
                    }
                    if (queuedObj.size !== null) {
                        sizeSlider.setValue(queuedObj.size);
                        queuedObj.size = null;
                    }
                    if (queuedObj.opacity !== null) {
                        opacitySlider.setValue(queuedObj.opacity);
                        queuedObj.opacity = null;
                    }

                    updateUI();
                }

            },
            onUp: (keyStr, event, oldComboStr) => {
                if (['ctrl+alt', 'cmd+alt', 'alt+ctrl', 'alt+cmd'].includes(oldComboStr) && isVisible) {
                    isVisible = false;
                    colorSlider.end();
                    updateUI();
                }
            },
            onBlur: () => {
                if (isVisible) {
                    isVisible = false;
                    colorSlider.end();
                    updateUI();
                }
            },
        });
    }

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


code:
import {BB} from '../../../bb/bb';
import {PointerListener} from '../../../bb/input/pointer-listener';

/**
 * A slider that looks like this
 * ------O----
 */
export class PointSlider {

    private readonly rootEl: HTMLElement;
    private readonly sliderPoint: HTMLElement;
    private readonly pointerListener: PointerListener;

    // ---- public ----
    constructor (
        p: {
            init: number;
            width: number;
            pointSize: number;
            callback: (value: number, isFirst: boolean, isLast: boolean) => void;
        }
    ) {
        this.rootEl = BB.el({
            css: {
                position: 'relative',
            },
        });
        const sliderLine = BB.el({
            parent: this.rootEl,
            className: 'kl-point-slider__line',
            css: {
                marginTop: parseInt('' + (p.pointSize / 2 - 1)) + 'px',
                width: p.width + 'px',
            },
        });
        this.sliderPoint = BB.el({
            parent: this.rootEl,
            className: 'kl-point-slider__point',
        });
        let sliderPos: number;
        let isDragging = false;

        //sliderPoint
        const touchAreaEl = BB.el({ // expand clickable area
            parent: this.sliderPoint,
            css: {
                // background: 'rgba(255,0,0,0.4)',
                margin: '-7px 0 0 -7px',
                width: 'calc(100% + 14px)',
                height: 'calc(100% + 7px)',
            },
        });

        const redrawPoint = () => {
            BB.css(this.sliderPoint, {
                left: sliderPos + 'px',
            });
        };
        const getValue = () => {
            return sliderPos /  (p.width - p.pointSize);
        };

        {
            let isFirst: boolean;
            sliderPos = BB.clamp(p.init * (p.width - p.pointSize), 0, p.width - p.pointSize);
            BB.css(this.sliderPoint, {
                width: p.pointSize + 'px',
                height: p.pointSize + 'px',
                borderRadius: p.pointSize + 'px',
            });
            redrawPoint();
            let imaginaryPos: number;
            this.pointerListener = new BB.PointerListener({
                target: this.sliderPoint,
                fixScribble: true,
                onPointer: (event): void => {
                    if (event.type === 'pointerdown' && event.button === 'left') {
                        isFirst = true;
                        isDragging = true;
                        imaginaryPos = sliderPos;
                        redrawPoint();
                        event.eventStopPropagation();
                    } else if (event.type === 'pointermove' && event.button === 'left') {
                        event.eventStopPropagation();
                        imaginaryPos = imaginaryPos + event.dX;
                        sliderPos = parseInt('' + BB.clamp(imaginaryPos, 0, p.width - p.pointSize));
                        redrawPoint();
                        p.callback(getValue(), isFirst, false);
                        isFirst = false;
                    }
                    if (event.type === 'pointerup') {
                        event.eventStopPropagation();
                        isDragging = false;
                        redrawPoint();
                        p.callback(getValue(), false, true);
                    }
                },
            });
        }
    }

    // ---- interface ----

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

    setActive (isActive: boolean): void {
        this.sliderPoint.style.backgroundColor = isActive ? '#fff' : '#eaeaea';
    }

    destroy (): void {
        this.pointerListener.destroy();
    }
}


code:
import {BB} from '../../../bb/bb';

export class RadioList {

    el: HTMLDivElement;
    inputs: HTMLInputElement[] = [];

    constructor ({name, init, items, ignoreFocus}: {
        name: string;
        init?: string;
        items: {
            label: string;
            value: string;
        }[];
        ignoreFocus?: boolean; // default false
    }) {
        this.el = BB.el({
            className: 'kl-radio',
        });

        items.forEach(item => {
            const label = BB.el({
                tagName: 'label',
            });
            const input = BB.el({
                tagName: 'input',
                parent: label,
                custom: {
                    name: name,
                    value: item.value,
                    type: 'radio',
                },
            });
            if (ignoreFocus) {
                input.setAttribute('data-ignore-focus', 'true');
            }
            if (init === item.value) {
                input.checked = true;
            }
            label.append(item.label);
            this.el.append(label);
            this.inputs.push(input);
        });
    }

    getValue (): string | null {
        for (let i = 0; i < this.inputs.length; i++) {
            if (this.inputs[i].checked) {
                return this.inputs[i].value;
            }
        }
        return null;
    }

    getElement () {
        return this.el;
    }

}


code:
import {KL} from '../../kl';
import {BB} from '../../../bb/bb';
import {KlHistoryInterface} from '../../history/kl-history';
import {LANG} from '../../../language/language';
import {BrowserStorageUi} from './browser-storage-ui';
import {ProjectStore} from '../../storage/project-store';
import {IKlProject} from '../../kl-types';

const reminderTimeLimitMs = 1000 * 60 * 20; // 20 minutes
const unsavedActionsLimit = 100; // number of actions user did since last save

/**
 * remind user of saving, keep user aware of save state
 */
export class SaveReminder {
    private lastSavedActionNumber: number | undefined;
    private lastSavedAt: number = 0;

    private lastReminderShownAt: number = 0;
    private unsavedInterval: ReturnType<typeof setInterval> | undefined;
    private closeFunc: (() => void) | undefined;

    showPopup (): void {
        if (!this.projectStore || !this.getProject) {
            throw new Error('projectStore and getProject need to be set');
        }

        const min = Math.round((performance.now() - this.lastSavedAt) / 1000 / 60);

        const contentEl = BB.el();

        contentEl.append(
            BB.el({
                content: LANG(
                    'save-reminder-text',
                    {
                        a: '<strong>' + min,
                        b: '</strong>',
                    }
                ),
                css: {
                    marginBottom: '20px',
                },
            }),
        );

        const psdWrapper = BB.el({
            css: {
                borderTop: '1px solid #aaa',
                margin: '0 -20px',
                padding: '20px',
            },
        });
        const storageWrapper = BB.el({
            css: {
                borderTop: '1px solid #aaa',
                margin: '0 -20px',
                padding: '20px',
                paddingBottom: '0',
            },
        });
        contentEl.append(psdWrapper, storageWrapper);

        const psdBtn = BB.el({
            tagName: 'button',
            className: 'kl-button',
            content: LANG('save-reminder-save-psd'),
            onClick: () => this.onSaveAsPsd(),
        });
        psdWrapper.append(
            psdBtn,
            BB.el({
                content: LANG('save-reminder-psd-layers'),
                css: {
                    marginTop: '10px',
                },
            }),
        );

        const storageUi = new BrowserStorageUi(
            this.projectStore,
            this.getProject,
            this,
            document.body,
            {
                hideClearButton: true,
                isFocusable: true,
            }
        );
        storageWrapper.append(storageUi.getElement());

        KL.popup({
            target: document.body,
            message: `<b>${LANG('save-reminder-title')}</b>`,
            div: contentEl,
            ignoreBackground: true,
            callback: () => {
                storageUi.destroy();
                BB.destroyEl(psdBtn);
                this.closeFunc = undefined;
                this.lastReminderShownAt = performance.now();
            },
            closeFunc: (f) => {
                this.closeFunc = f;
            },
        });
        setTimeout(() => {
            psdBtn.focus();
        }, 40);
    }

    constructor (
        private history: KlHistoryInterface,
        private showReminder: boolean,
        private changeTitle: boolean,
        private onSaveAsPsd: () => void,
        private isDrawing: () => boolean,
        private projectStore: ProjectStore | null, // needed if showReminder
        private getProject: (() => IKlProject) | null, // needed if showReminder
        private title: string = 'Klecks',
    ) { }

    init (): void {
        if (this.lastSavedActionNumber !== undefined) { // already initialized
            return;
        }
        this.lastSavedActionNumber = this.history.getActionNumber();
        this.lastReminderShownAt = performance.now();
        this.lastSavedAt = performance.now();

        if (this.showReminder) {
            setInterval(() => {
                if (document.visibilityState !== 'visible') {
                    return;
                }

                const unsavedActions = Math.abs(this.history.getActionNumber() - this.lastSavedActionNumber!);

                if (
                    KL.dialogCounter.get() === 0 &&
                    !this.isDrawing() &&
                    this.lastReminderShownAt + reminderTimeLimitMs < performance.now() &&
                    unsavedActions >= unsavedActionsLimit
                ) {
                    this.showPopup();
                }
            }, 1000 * 5);
        }

        // confirmation dialog when closing tab
        function onBeforeUnload (e: BeforeUnloadEvent) {
            e.preventDefault();
            e.returnValue = '';
        }

        this.history.addListener(() => {
            const actionNumber = this.history.getActionNumber();
            if (this.lastSavedActionNumber !== actionNumber) {
                window.onbeforeunload =  onBeforeUnload;
            } else {
                window.onbeforeunload = null;
            }
        });

        if (this.changeTitle) {
            document.addEventListener('visibilitychange', () => {
                if (document.visibilityState === 'visible') {
                    document.title = this.title;
                    clearInterval(this.unsavedInterval);
                } else {
                    const actionNumber = this.history.getActionNumber();
                    if (this.lastSavedActionNumber !== actionNumber) {
                        document.title = LANG('unsaved') + ' - ' + this.title;
                        let state = 0;
                        this.unsavedInterval = setInterval(() => {
                            state = (state + 1) % 2;
                            if (state === 1) {
                                document.title = LANG('unsaved') + ' · ' + this.title;
                            } else {
                                document.title = LANG('unsaved') + ' - ' + this.title;
                            }
                        }, 1000 * 60 * 3);
                    }
                }
            });
        }
    }

    reset (): void {
        if (this.lastSavedActionNumber === undefined) { // not initialized
            return;
        }

        this.lastSavedActionNumber = this.history.getActionNumber();
        this.lastReminderShownAt = performance.now();
        this.lastSavedAt = performance.now();
        window.onbeforeunload = null;

        if (this.closeFunc) {
            this.closeFunc();
        }
    }
}


code:
import {BB} from '../../../bb/bb';
import {IKeyString, IKeyStringOptional} from '../../../bb/bb-types';

type TSelectItem<ValueType> = [ValueType, string] | [ValueType, string, { css: IKeyStringOptional }]; // [value, label, properties]

/**
 * A select dropdown
 */
export class Select<ValueType extends string> {

    private readonly selectEl: HTMLSelectElement;
    private optionArr: {
        item: (TSelectItem<ValueType> | undefined);
        el?: HTMLOptionElement;
    }[] = [];
    private readonly changeListener: () => void;
    private readonly onChange: ((val: ValueType) => void) | undefined;

    // --- public ---
    constructor (p: {
        isFocusable?: boolean; // default false
        optionArr: (TSelectItem<ValueType> | undefined)[];
        initValue?: ValueType; // default ''
        onChange?: (val: ValueType) => void;
        css?: IKeyString;
        title?: string;
    }) {
        this.selectEl = BB.el({
            tagName: 'select',
            title: p.title,
            className: 'kl-select',
            css: {
                cursor: 'pointer',
                fontSize: '15px',
                padding: '3px',
            },
        });
        if (p.css) {
            BB.css(this.selectEl, p.css);
        }

        const isFocusable = p.isFocusable;
        if (!isFocusable) {
            this.selectEl.tabIndex = -1;
        }
        this.setOptionArr(p.optionArr);
        if (p.onChange) {
            this.onChange = p.onChange;
        }
        this.changeListener = () => {
            if (!isFocusable) {
                this.selectEl.blur();
            }
            this.onChange && this.onChange(this.getValue());
        };
        this.selectEl.addEventListener('change', this.changeListener);
        this.selectEl.value = p.initValue !== undefined ? p.initValue : '';
    }

    setValue (val: ValueType | undefined): void {
        this.selectEl.value = val === undefined ? '' : val;
    }

    getValue (): ValueType {
        return this.selectEl.value as ValueType;
    }

    setDeltaValue (delta: number): void {
        let index = 0;
        for (let i = 0; i < this.optionArr.length; i++) {
            const option = this.optionArr[i];
            if (option.item && '' + option.item[0] === this.selectEl.value) {
                index = i;
                break;
            }
        }
        index = Math.max(0, Math.min(this.optionArr.length -1, index + delta));
        const option = this.optionArr[index];
        this.selectEl.value = option.item ? option.item[0] : '';
        this.onChange && this.onChange(this.getValue());
    }

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

    updateLabel (id: ValueType, label: string): void {
        this.optionArr.forEach(option => {
            if (option.item && option.item[0] === id && option.el) {
                option.item[1] = label;
                option.el.textContent = option.item[1];
            }
        });
    }

    setOptionArr (optionArr: (TSelectItem<ValueType> | undefined)[]): void {
        this.optionArr = [];
        this.selectEl.innerHTML = '';
        for (let i = 0; i < optionArr.length; i++) {
            const item = optionArr[i];
            if (!item) {
                this.optionArr.push({
                    item,
                });
                continue;
            }
            const el = document.createElement('option');
            el.value = item[0];
            el.textContent = item[1];
            if (item[2]) {
                BB.css(el, item[2].css);
            }
            this.optionArr.push({
                item,
                el,
            });
            this.selectEl.append(el);
        }
    }

    destroy (): void {
        this.selectEl.removeEventListener('change', this.changeListener);
    }

}


code:
/**
 * To make sliders more fine-grained. The falloff when moving cursor away from cursor.
 * Returns the factor [0,1]
 *  0 -> infinite movement required for change of 1
 *  1 -> 1px movement for change of 1
 *
 * @param deltaY vertical distance from pointerdown
 * @param isRightButton
 */
export function calcSliderFalloffFactor (deltaY: number, isRightButton: boolean): number {
    let result = Math.min(10, 1 + Math.pow(Math.floor(deltaY / 50), 2));
    if (isRightButton) {
        result *= 2;
    }
    return 1 / result;
}


code:
import {BB} from '../../../bb/bb';
import angleSvg from '/src/app/img/ui/angle.svg';

type TUiState = 'left' | 'right';

/**
 * display little messages at the top of KlWorkspace
 */
export class StatusOverlay {

    private readonly el: HTMLDivElement;
    private readonly innerEl: HTMLDivElement;
    private readonly angleIm: HTMLImageElement;
    private readonly innerInnerEl: HTMLDivElement;
    private isWide: boolean = false; // tool space hidden
    private uiState: TUiState = 'right';

    private timeout: ReturnType<typeof setTimeout> | undefined;
    private timeout2: ReturnType<typeof setTimeout> | undefined;
    private timeout3: ReturnType<typeof setTimeout> | undefined;

    private updateUiState () {
        if (this.uiState === 'left') {
            this.el.style.left = '271px';
        } else {
            this.el.style.removeProperty('left');
        }
    }

    // --- public ---

    constructor () {
        this.el = BB.el({
            className: 'top-overlay g-root',
            onClick: BB.handleClick,
        });

        this.updateUiState();

        this.innerEl = BB.el({
            className: 'top-overlay--inner',
        });

        this.angleIm = new Image();
        this.angleIm.src = angleSvg;
        BB.css(this.angleIm, {
            verticalAlign: 'bottom',
            width: '20px',
            height: '20px',
            marginLeft: '5px',
            borderRadius: '10px',
        });

        this.innerInnerEl = document.createElement('div');
        this.innerInnerEl.style.display = 'inline-block';

        this.innerEl.append(this.innerInnerEl, this.angleIm);
        this.el.append(this.innerEl);
        document.body.append(this.el);
        this.el.style.display = 'none';
    }

    setWide (b: boolean) {
        this.isWide = b;

        if (!this.el) {
            return;
        }

        if (this.isWide) {
            this.el.style.width = '100%';
            this.el.style.left = '';
        } else {
            this.el.style.removeProperty('width');
            this.el.style.left = this.uiState === 'left' ? '271px' : '';
        }
    }

    setUiState (state: TUiState) {
        this.uiState = state;
        this.updateUiState();
    }

    out (msg: string | {type: 'transform'; angleDeg: number; scale: number}, doPulse?: boolean) {

        if (typeof msg === 'string') {
            this.angleIm.style.display = 'none';
            this.innerInnerEl.innerHTML = msg;
        } else {
            if (msg.type === 'transform') {
                this.angleIm.style.display = 'inline-block';
                this.angleIm.style.transform = 'rotate(' + msg.angleDeg + 'deg)';

                if (msg.angleDeg % 90 === 0) {
                    this.angleIm.style.boxShadow = 'inset 0 0 0 1px rgba(255, 255, 255, 0.7)';
                } else {
                    this.angleIm.style.boxShadow = '';
                }

                this.innerInnerEl.innerHTML = Math.round(msg.scale * 100) + '%';

            } else {
                this.angleIm.style.display = 'none';
            }
        }

        if (doPulse) {
            this.innerEl.style.animation = '';
            setTimeout(
                () => this.innerEl.style.animation = 'topOverlayPulse 0.5s ease-out',
                20
            );
            clearTimeout(this.timeout3);
            this.timeout3 = setTimeout(
                () => this.innerEl.style.animation = '',
                520
            );
        }

        clearTimeout(this.timeout);
        clearTimeout(this.timeout2);
        this.el.style.animationName = doPulse ? 'consoleInFast' : 'consoleIn';
        this.el.style.opacity = '1';
        this.timeout = setTimeout(() => {
            this.el.style.opacity = '0';
            this.el.style.animationName = 'consoleOut';
            this.timeout2 = setTimeout(() => {
                this.el.style.display = 'none';
            }, 450);
        }, 1200);
        this.el.style.display = 'flex';
    }

}


code:
import {BB} from '../../../bb/bb';
import invertedBorderImg from '/src/app/img/ui/inverted-border.svg';
import {IKeyStringOptional} from '../../../bb/bb-types';
import {PointerListener} from '../../../bb/input/pointer-listener';

// type all functions

type TTabInit = {
    id: string; // e.g. 'draw',
    label?: string;
    image?: string; // background image
    title?: string;
    isVisible?: boolean; // default is true
    onOpen: () => void;
    onClose: () => void;
    css?: IKeyStringOptional;
};

type TTab = {
    id: string;
    isVisible: boolean;
    onOpen: () => void;
    onClose: () => void;
    update: (activeTab: TTab) => void;
    el: HTMLElement;
    pointerListener: PointerListener;
};

/**
 * row of tabs. uses css class .tabrow__tab
 */
export class TabRow {

    private readonly rootEl: HTMLElement;
    private readonly tabArr: TTab[];
    private activeTab: TTab;
    private readonly roundRight: HTMLElement;
    private readonly roundLeft: HTMLElement;

    // update
    update (): void {
        for (let i = 0; i < this.tabArr.length; i++) {
            this.tabArr[i].update(this.activeTab);
        }
    }

    // ---- public ----
    constructor (
        p: {
            initialId: string; // e.g. 'draw'
            useAccent?: boolean;
            tabArr: TTabInit[];
            height?: number;
        }
    ) {
        const height = p.height ?? 35;
        this.rootEl = BB.el({
            className: 'tabrow',
            css: {
                height: height + 'px',
            },
        });

        this.tabArr = []; //creates its own internal arr

        const roundSize = 10;
        this.roundRight = BB.el({
            css: {
                width: roundSize + 'px',
                height: roundSize + 'px',
                backgroundImage: `url('${invertedBorderImg}')`,
                backgroundSize: 'cover',
                position: 'absolute',
                right: -roundSize + 'px',
                bottom: '0',
                pointerEvents: 'none',
            },
        });
        this.roundLeft = BB.el({
            css: {
                width: roundSize + 'px',
                height: roundSize + 'px',
                backgroundImage: `url('${invertedBorderImg}')`,
                backgroundSize: 'cover',
                position: 'absolute',
                left: -roundSize + 'px',
                bottom: '0',
                transform: 'scale(-1,1)',
                pointerEvents: 'none',
            },
        });

        const createTab = (pTabObj: TTabInit, initialId: string, useAccent: boolean): TTab => {
            const isVisible = ('isVisible' in pTabObj && pTabObj.isVisible !== undefined) ? pTabObj.isVisible : true;
            const result: TTab = {
                id: pTabObj.id,
                isVisible: isVisible,
                onOpen: pTabObj.onOpen,
                onClose: pTabObj.onClose,
                update: (openedTabObj: TTab) => {
                    result.el.className = openedTabObj === result ? (useAccent ? 'tabrow__tab tabrow__tab--opened-accented' : 'tabrow__tab tabrow__tab--opened') : 'tabrow__tab';
                    result.el.style.display = result.isVisible ? 'block' : 'none';
                },
                el: BB.el({
                    parent: this.rootEl,
                    content: 'label' in pTabObj ? pTabObj.label : '',
                    title: 'title' in pTabObj ? pTabObj.title : undefined,
                    className: initialId === pTabObj.id ? (useAccent ? 'tabrow__tab tabrow__tab--opened-accented' : 'tabrow__tab tabrow__tab--opened') : 'tabrow__tab',
                    css: {
                        lineHeight: height + 'px',
                        display: isVisible ? 'block' : 'none',
                        zIndex: '0',
                    },
                    onClick: () => {
                        if (this.activeTab === result) {
                            return;
                        }
                        this.open(result.id);
                    },
                }),
                pointerListener: {} as PointerListener,
            };
            if ('image' in pTabObj) {

                BB.el({
                    tagName: 'span',
                    parent: result.el,
                    className: 'dark-invert',
                    css: {
                        backgroundImage: `url("${pTabObj.image}")`,
                        backgroundSize: 'contain',
                        backgroundPosition: 'center',
                        backgroundRepeat: 'no-repeat',
                        display: 'flex',
                        height: (height - 7) + 'px',
                        justifyContent: 'center',
                        margin: '4px auto',
                    },
                });
            }
            if ('css' in pTabObj && pTabObj.css !== undefined) {
                BB.css(result.el, pTabObj.css);
            }

            result.pointerListener = new BB.PointerListener({ // because :hover causes problems w touch
                target: result.el,
                onEnterLeave: (isOver) => {
                    result.el.classList.toggle('tabrow__tab-hover', isOver);
                },
            });

            if (initialId === result.id) {
                result.onOpen();
                result.el.append(this.roundRight, this.roundLeft);
            } else {
                result.onClose();
            }

            return result;
        };

        let initTabObj = null;
        for (let i = 0; i < p.tabArr.length; i++) {
            const tab = createTab(p.tabArr[i], p.initialId, p.useAccent || false);
            if (tab.id === p.initialId) {
                initTabObj = tab;
            }
            this.tabArr.push(tab);
        }
        if (!initTabObj) {
            throw 'invalid initialId';
        }
        this.activeTab = initTabObj;
    }

    // ---- interface ----

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

    open (tabId: string): void {
        for (let i = 0; i < this.tabArr.length; i++) {
            if (this.tabArr[i].id === tabId) {
                if (this.activeTab === this.tabArr[i]) { // already open
                    return;
                }
                this.activeTab.onClose();
                this.activeTab = this.tabArr[i];
                this.activeTab.onOpen();
                this.activeTab.el.append(this.roundRight, this.roundLeft);
                this.update();
                return;
            }
        }
        throw 'TabRow.open - invalid tabId';
    }

    getOpenedTabId (): string {
        return '' + this.activeTab.id;
    }

    setIsVisible (tabId: string, isVisible: boolean): void {
        for (let i = 0; i < this.tabArr.length; i++) {
            if (this.tabArr[i].id === tabId) {
                this.tabArr[i].isVisible = isVisible;
                this.update();
                return;
            }
        }
        throw 'TabRow.setIsVisible - invalid tabId';
    }

    destroy (): void {
        this.tabArr.forEach(item => {
            BB.destroyEl(item.el);
            item.pointerListener.destroy();
        });
    }
}


code:
import {BB} from '../../../bb/bb';

export function table (
    data: (HTMLElement | string)[][],
    cellProps?: Record<string, {rowspan: number}>, // {'0.2': {...}} -> row 1, col 3
): HTMLElement {
    const result = BB.el({
        tagName: 'table',
        className: 'kl-table',
    });
    data.forEach((row, rowIndex) => {
        const rowEl = BB.el({
            tagName: 'tr',
        });
        rowEl.append(...row.map((el, colIndex) => {
            const cellEl = BB.el({
                tagName: 'td',
                content: el,
            });

            const key = rowIndex + '.' + colIndex;
            if (cellProps !== undefined && key in cellProps) {
                cellEl.rowSpan = cellProps[key].rowspan;
            }

            return cellEl;
        }));
        result.append(rowEl);
    });
    return result;
}