code:
import {BB} from '../../bb/bb';
import {IRGB, IShapeToolObject} from '../kl-types';

/**
 * Input processor for shape tool.
 * Coordinates are in canvas space.
 * angleRad is the angle of the canvas.
 */
export class ShapeTool {

    private readonly onShape: (isDone: boolean, x1: number, y1: number, x2: number, y2: number, angleRad: number) => void;
    private downX: number = 0;
    private downY: number = 0;
    private downAngleRad: number = 0;

    // ---- public ----
    constructor (
        p: {
            onShape: (isDone: boolean, x1: number, y1: number, x2: number, y2: number, angleRad: number) => void;
        }
    ) {
        this.onShape = p.onShape;
    }

    onDown (x: number, y: number, angleRad: number): void {
        this.downX = x;
        this.downY = y;
        this.downAngleRad = angleRad;
    }

    onMove (x: number, y: number): void {
        this.onShape(false, this.downX, this.downY, x, y, this.downAngleRad);
    }

    onUp (x: number, y: number): void {
        this.onShape(true, this.downX, this.downY, x, y, this.downAngleRad);
    }
}

/**
 * Draw a shape (rectangle, ellipse, line)
 */
export function drawShape (
    ctx: CanvasRenderingContext2D,
    shapeObj: IShapeToolObject,
): void {
    shapeObj = {
        // defaults
        angleRad: 0,
        isOutwards: false,
        opacity: 1,
        isEraser: false,
        doLockAlpha: false,

        ...BB.copyObj(shapeObj),
    };

    if (['rect', 'ellipse', 'line'].includes(shapeObj.type)) {
        if (shapeObj.angleRad === undefined) {
            throw new Error('angleRad undefined');
        }

        const lineWidth = shapeObj.lineWidth === undefined ? -1 : Math.round(shapeObj.lineWidth);
        const angleDeg = shapeObj.angleRad * 180 / Math.PI;

        // --- prep color ---
        if (!shapeObj.isEraser && shapeObj.fillRgb === undefined && shapeObj.strokeRgb === undefined) {
            throw new Error('fillRgb and strokeRgb undefined');
        }
        const colorRGB: IRGB = shapeObj.isEraser ? {r: 255, g: 255, b: 255} : (shapeObj.fillRgb ? shapeObj.fillRgb : shapeObj.strokeRgb!);

        // --- prep canvas ---
        ctx.save();
        if (shapeObj.opacity) {
            ctx.globalAlpha = shapeObj.opacity;
        }
        if (shapeObj.isEraser) {
            ctx.globalCompositeOperation = 'destination-out';
        }
        if (shapeObj.doLockAlpha) {
            ctx.globalCompositeOperation = 'source-atop';
        }
        ctx.rotate(-shapeObj.angleRad);
        if (shapeObj.fillRgb) {
            ctx.fillStyle = BB.ColorConverter.toRgbStr(colorRGB);
        } else if (shapeObj.strokeRgb) {
            ctx.strokeStyle = BB.ColorConverter.toRgbStr(colorRGB);
            ctx.lineWidth = lineWidth;
        }

        let x1 = shapeObj.x1;
        let y1 = shapeObj.y1;
        let x2 = shapeObj.x2;
        let y2 = shapeObj.y2;

        // --- angle snapping ---
        if (shapeObj.isAngleSnap) {
            const r1 = BB.rotate(x1, y1, shapeObj.angleRad / Math.PI * 180);
            const r2 = BB.rotate(x2, y2, shapeObj.angleRad / Math.PI * 180);

            const pAngleDeg = BB.pointsToAngleDeg(r1, r2) + 90;
            const pAngleDegSnapped = Math.round(pAngleDeg / 45) * 45;
            const rotated = BB.rotateAround({x: x1, y: y1}, {x: x2, y: y2}, pAngleDegSnapped - pAngleDeg);
            x2 = rotated.x;
            y2 = rotated.y;

            // needs to be perfect if p1->p2 aligns with canvas x- or y-axis
            if ((angleDeg + pAngleDegSnapped) % 90 === 0) {
                if (Math.round((angleDeg - pAngleDegSnapped) / 90) % 2 === 0) { // up or down
                    x2 = x1;
                } else { // left or right
                    y2 = y1;
                }
            }
        }

        let x = x1;
        let y = y1;
        let dX = x2 - x1;
        let dY = y2 - y1;

        // --- 1:1 ratio ---
        if (shapeObj.type !== 'line' && shapeObj.isFixedRatio) {
            let r1 = BB.rotate(shapeObj.x1, shapeObj.y1, shapeObj.angleRad / Math.PI * 180);
            let r2 = BB.rotate(shapeObj.x2, shapeObj.y2, shapeObj.angleRad / Math.PI * 180);

            const rx = r1.x;
            const ry = r1.y;
            let rdX = r2.x - r1.x;
            let rdY = r2.y - r1.y;

            if (Math.abs(rdX) < Math.abs(rdY)) {
                rdY = Math.abs(rdX) * (rdY < 0 ? -1 : 1);
            } else {
                rdX = Math.abs(rdY) * (rdX < 0 ? -1 : 1);
            }
            r2.x = rx + rdX;
            r2.y = ry + rdY;

            r1 = BB.rotate(r1.x, r1.y, -shapeObj.angleRad / Math.PI * 180);
            r2 = BB.rotate(r2.x, r2.y, -shapeObj.angleRad / Math.PI * 180);

            x1 = r1.x;
            y1 = r1.y;
            x2 = r2.x;
            y2 = r2.y;

            x = x1;
            y = y1;
            dX = x2 - x1;
            dY = y2 - y1;
        }

        // outwards modifier
        if (shapeObj.isOutwards) {
            x -= dX;
            y -= dY;
            dX *= 2;
            dY *= 2;

            x1 = x;
            y1 = y;
            x2 = x + dX;
            y2 = y + dY;
        }

        let p1;
        let p2;
        if (shapeObj.type === 'line') { // --- line ---

            // rounded
            const x1r = Math.round(x1);
            const y1r = Math.round(y1);
            const x2r = Math.round(x2);
            const y2r = Math.round(y2);

            // floored
            const x1f = Math.floor(x1);
            const y1f = Math.floor(y1);
            const x2f = Math.floor(x2);
            const y2f = Math.floor(y2);

            if (lineWidth % 2 === 0) {

                if (y1r === y2r) {
                    p1 = {
                        x: x1f,
                        y: y1r,
                    };
                    p2 = {
                        x: x2f,
                        y: y2r,
                    };

                    if (x1f < x2f) {
                        p2.x += 1;
                    } else {
                        p1.x += 1;
                    }
                } else if (x1r === x2r) {
                    p1 = {
                        x: x1r,
                        y: y1f,
                    };
                    p2 = {
                        x: x2r,
                        y: y2f,
                    };

                    if (y1f < y2f) {
                        p2.y += 1;
                    } else {
                        p1.y += 1;
                    }
                } else {
                    p1 = {
                        x: x1,
                        y: y1,
                    };
                    p2 = {
                        x: x2,
                        y: y2,
                    };
                }

            } else {
                p1 = {
                    x: x1f,
                    y: y1f,
                };
                p2 = {
                    x: x2f,
                    y: y2f,
                };
                if (y1f === y2f) {
                    if (x1f < x2f) {
                        p2.x += 1;
                    } else {
                        p1.x += 1;
                    }
                    p1.y += 0.5;
                    p2.y += 0.5;
                } else if (x1f === x2f) {
                    if (y1f < y2f) {
                        p2.y += 1;
                    } else {
                        p1.y += 1;
                    }
                    p1.x += 0.5;
                    p2.x += 0.5;
                } else {
                    p1.x = x1;
                    p1.y = y1;
                    p2.x = x2;
                    p2.y = y2;
                }
            }

            p1 = BB.rotate(p1.x, p1.y, shapeObj.angleRad / Math.PI * 180);
            p2 = BB.rotate(p2.x, p2.y, shapeObj.angleRad / Math.PI * 180);

            ctx.beginPath();
            ctx.moveTo(p1.x, p1.y);
            ctx.lineTo(p2.x, p2.y);
            ctx.stroke();

        } else if (shapeObj.type === 'rect') { // --- rect ---

            // floored
            const x1f = Math.floor(x1);
            const y1f = Math.floor(y1);
            const x2f = Math.floor(x2);
            const y2f = Math.floor(y2);

            if (angleDeg % 90 === 0) {
                if (shapeObj.fillRgb) {
                    if (x1 % 1 === 0) {
                        x1 += 1;
                    }
                    if (y1 % 1 === 0) {
                        y1 += 1;
                    }
                    if (x2 % 1 === 0) {
                        x2 += 1;
                    }
                    if (y2 % 1 === 0) {
                        y2 += 1;
                    }

                    p1 = {
                        x: x1 < x2 ? x1f : x2f,
                        y: y1 < y2 ? y1f : y2f,
                    };
                    p2 = {
                        x: Math.ceil((x1 < x2 ? x2 : x1) - p1.x),
                        y: Math.ceil((y1 < y2 ? y2 : y1) - p1.y),
                    };
                    p2.x = p1.x + p2.x;
                    p2.y = p1.y + p2.y;

                } else {
                    if (lineWidth % 2 === 0) {
                        p1 = {
                            x: x1f,
                            y: y1f,
                        };
                        p2 = {
                            x: x2f,
                            y: y2f,
                        };
                    } else {
                        p1 = {
                            x: x1f + 0.5,
                            y: y1f + 0.5,
                        };
                        p2 = {
                            x: x2f + 0.5,
                            y: y2f + 0.5,
                        };
                    }
                }
            } else {
                p1 = {
                    x: x1,
                    y: y1,
                };
                p2 = {
                    x: x2,
                    y: y2,
                };
            }

            p1 = BB.rotate(p1.x, p1.y, shapeObj.angleRad / Math.PI * 180);
            p2 = BB.rotate(p2.x, p2.y, shapeObj.angleRad / Math.PI * 180);
            p2.x = p2.x - p1.x;
            p2.y = p2.y - p1.y;

            if (shapeObj.fillRgb) {
                ctx.fillRect(p1.x, p1.y, p2.x, p2.y);
            } else {
                ctx.strokeRect(p1.x, p1.y, p2.x, p2.y);
            }

        } else {  // --- circle ---
            p1 = BB.rotate(x1, y1, shapeObj.angleRad / Math.PI * 180);
            p2 = BB.rotate(x2, y2, shapeObj.angleRad / Math.PI * 180);
            x = p1.x;
            y = p1.y;
            dX = p2.x - p1.x;
            dY = p2.y - p1.y;

            ctx.beginPath();
            ctx.ellipse(x + dX / 2, y + dY / 2, Math.abs(dX / 2), Math.abs(dY / 2), 0, 0, Math.PI * 2);
            if (shapeObj.fillRgb) {
                ctx.fill();
            } else {
                ctx.stroke();
            }
        }

        ctx.restore();

    } else {
        throw new Error('unknown shape');
    }
}


code:
type TKlConfig = {
    filenameBase: string; // part of filename when downloading drawing
    disableAutoFit?: boolean;
};

export const klConfig: TKlConfig = {
    filenameBase: 'Klecks',
};

repo_name: Doggermelon/klecks

license: mit


code:
import {KlHistoryInterface} from './history/kl-history';
import {KlCanvas} from './canvas/kl-canvas';
import {TTranslationCode} from '../../languages/languages';

export interface IFilterApply<T = unknown> {
    context: CanvasRenderingContext2D; // context of selected layer
    klCanvas: KlCanvas;
    input: T; // parameters chosen in modal
    history: KlHistoryInterface;
}

export interface IFilterGetDialogParam {
    context: CanvasRenderingContext2D; // context of selected layer
    klCanvas: KlCanvas;
    maxWidth: number; // limit for klCanvas size
    maxHeight: number;
    currentColorRgb: IRGB;
    secondaryColorRgb: IRGB;
}

export interface IFilterGetDialogResult<T = unknown> {
    element: HTMLElement; // contents of modal (excluding title, dialog buttons)
    destroy?: () => void; // called when modal closed
    width?: number; // custom modal width
    getInput?: () => T; // called when Ok pressed
    errorCallback?: (e: Error) => void; // dialog can call this if error happens and cancel dialog
}

export interface IFilter {
    lang: {
        name: TTranslationCode; // title in modal
        button: TTranslationCode; // text on button in filter tab
    };
    updatePos: boolean; // changes size/orientation of klCanvas
    icon: string; // image url
    isInstant?: boolean; // default false - if instant no modal
    inEmbed: boolean; // is available in embed
    getDialog: null | ((p: IFilterGetDialogParam) => any);
    apply: null | ((p: IFilterApply) => boolean);
    darkNoInvert?: boolean;
    webGL?: boolean; // does the filter require webgl
}

export interface ITransform {
    x: number;
    y: number;
    scale: number;
    angle: number; // rad
}

export type TKlCanvasLayer = {
    context: CanvasRenderingContext2D;
    isVisible: boolean;
    opacity: number;
    name: string;
    id: number;
};

// a subset of CanvasRenderingContext2D.globalCompositeOperation
export type TMixMode = (
    'source-over' | // default aka normal
    'darken' |
    'multiply' |
    'color-burn' |
    'lighten' |
    'screen' |
    'color-dodge' |
    'overlay' |
    'soft-light' |
    'hard-light' |
    'difference' |
    'exclusion' |
    'hue' |
    'saturation' |
    'color' |
    'luminosity'
    );

export type IKlBasicLayer = {
    opacity: number; // 0 - 1
    isVisible: boolean;
    mixModeStr?: TMixMode; // default "source-over"
    image: HTMLImageElement | HTMLCanvasElement; // already loaded
};

export type IKlProject = {
    width: number; // int
    height: number; // int
    layers: {
        name: string;
        isVisible: boolean;
        opacity: number; // 0 - 1
        mixModeStr?: TMixMode; // default "source-over"
        image: HTMLImageElement | HTMLCanvasElement; // already loaded
    }[];
};

// stored in indexedDB
export type IKlStorageProject = {
    id: 1;
    timestamp: number;
    thumbnail?: Blob; // png - may not exist pre 0.5.1
    width: number; // int
    height: number; // int
    layers: {
        name: string;
        isVisible: boolean;
        opacity: number; // 0 - 1
        mixModeStr?: TMixMode; // default "source-over"
        blob: Blob; // png
    }[];
};

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

export interface IRGB {
    r: number; // [0, 255]
    g: number;
    b: number;
}

export interface IRGBA {
    r: number; // [0, 255]
    g: number;
    b: number;
    a: number; // [0, 1]
}

export interface IInitState {
    canvas: KlCanvas;
    focus: number; // index of selected layer
    brushes: any; // todo type
}

export type TGradientType = 'linear' | 'linear-mirror' | 'radial';

export interface IGradient {
    type: TGradientType;
    color1: IRGB;
    isReversed: boolean; // color1 actually color2
    opacity: number; // [0, 1]
    doLockAlpha: boolean;
    doSnap: boolean; // 45° deg angle snapping
    x1: number;
    y1: number;
    x2: number;
    y2: number;
    angleRad: number; // angle of canvas
    isEraser: boolean;
}

export type TShapeToolType = 'rect' | 'ellipse' | 'line';

export type TShapeToolMode = 'stroke' | 'fill';

export interface IShapeToolObject {
    type: TShapeToolType;
    x1: number;
    y1: number;
    x2: number;
    y2: number;
    angleRad?: number; // angle of canvas, default 0
    isOutwards?: boolean; // center is x1 y1, default false
    opacity?: number; // 0-1, default 1
    isEraser?: boolean; // default false
    fillRgb?: { r: number; g: number; b: number }; // for rect or ellipse
    strokeRgb?: { r: number; g: number; b: number }; // needed for line
    lineWidth?: number; // needed for line
    isAngleSnap?: boolean; // 45° deg angle snapping
    isFixedRatio?: boolean; // 1:1 for rect or ellipse
    doLockAlpha?: boolean; // default false
}

export interface IKlSliderConfig {
    min: number;
    max: number;
    curve?: [number, number][] | 'quadratic';
    isDisabled?: boolean; // default enabled
}

export interface ISliderConfig {
    sizeSlider: IKlSliderConfig;
    opacitySlider: IKlSliderConfig;
}

export type TBrushUiInstance<GBrush> = {
    increaseSize: (f: number) => void;
    decreaseSize: (f: number) => void;
    getSize: () => number;
    setSize: (size: number) => void;
    getOpacity: () => number;
    setOpacity: (opacity: number) => void;
    setColor: (c: IRGB) => void;
    setContext: (ctx: CanvasRenderingContext2D) => void;
    startLine: (x: number, y: number, p: number) => void;
    goLine: (x: number, y: number, p: number, isCoalesced: boolean) => void;
    endLine: (x: number, y: number) => void;
    getBrush: () => GBrush;
    isDrawing: () => boolean;
    getElement: () => HTMLElement;
    getIsTransparentBg?: () => boolean;
    getSeed?: () => number;
    setSeed?: (s: number) => void;
    toggleEraser?: () => void;
};

export interface IBrushUi<GBrush> extends ISliderConfig {
    image: string;
    tooltip: string;
    Ui: (
        this: TBrushUiInstance<GBrush>,
        p: {
            onSizeChange: (size: number) => void;
            onOpacityChange: (size: number) => void;
            onConfigChange: () => void;
        }
    ) => TBrushUiInstance<GBrush>;
}

export type TPressureInput = {
    x: number;
    y: number;
    pressure: number;
};

export interface IDrawDownEvent {
    type: 'down';
    scale: number;
    shiftIsPressed: boolean;
    pressure: number;
    isCoalesced: boolean;
    x: number;
    y: number;
}

export interface IDrawMoveEvent {
    type: 'move';
    scale: number;
    shiftIsPressed: boolean;
    pressure: number;
    isCoalesced: boolean;
    x: number;
    y: number;
}

export interface IDrawUpEvent {
    type: 'up';
    scale: number;
    shiftIsPressed: boolean;
    isCoalesced: boolean;
}

export type TDrawEvent = IDrawDownEvent | IDrawMoveEvent | IDrawUpEvent | {
    type: 'line';
    x0: number | null;
    y0: number | null;
    x1: number;
    y1: number;
    pressure0: number | null;
    pressure1: number;
};

export type TToolType = 'draw' | 'fill' | 'text' | 'shape' | 'gradient' | 'hand';

export type TKlPsdError =
    'mask'
    | 'clipping'
    | 'group'
    | 'adjustment'
    | 'layer-effect'
    | 'smart-object'
    | 'blend-mode'
    | 'bits-per-channel';

export type TKlPsdLayer = {
    name: string;
    isVisible: boolean;
    mixModeStr: TMixMode;
    opacity: number;
    image: HTMLCanvasElement;
};

/**
 * Psd interpreted for usage in Klecks.
 */
export interface IKlPsd {
    type: 'psd';
    canvas: HTMLCanvasElement;
    width: number;
    height: number;
    layers?: TKlPsdLayer[]; // not there if flattened
    // if one of these features show up, they become a warning
    // because Klecks can't properly represent them (yet)
    warningArr?: TKlPsdError[];
    error?: boolean; // true if flattened (too many layers)
}

export type TFillSampling = 'current' | 'all' | 'above';

export type TUiLayout = 'left' | 'right';
export type TExportType = 'png' | 'layers' | 'psd';


code:
import {DecoyKlHistory, klHistory} from './history/kl-history';
import {showModal} from './ui/modals/base/showModal';
import {DynamicModal} from './ui/modals/base/dynamic-modal';
import {dialogCounter} from './ui/modals/modal-count';
import {Checkbox} from './ui/components/checkbox';
import {input} from './ui/components/input';
import {Select} from './ui/components/select';
import {ImageToggle} from './ui/components/image-toggle';
import {ImageRadioList} from './ui/components/image-radio-list';
import {createPenPressureToggle} from './ui/components/create-pen-pressure-toggle';
import {KlSlider} from './ui/components/kl-slider';
import {calcSliderFalloffFactor} from './ui/components/slider-falloff';
import {HexColorDialog} from './ui/modals/color-slider-hex-dialog';
import {KlColorSlider} from './ui/components/kl-color-slider';
import {KlColorSliderSmall} from './ui/components/kl-color-slider-small';
import {PointSlider} from './ui/components/point-slider';
import {ColorOptions} from './ui/components/color-options';
import {Options} from './ui/components/options';
import {StatusOverlay} from './ui/components/status-overlay';
import {CropCopy} from './ui/components/crop-copy';
import {clipboardDialog} from './ui/modals/clipboard-dialog';
import {LayerManager} from './ui/tool-tabs/layer-manager/layer-manager';
import {WorkspaceSvgOverlay} from './canvas-ui/workspace-svg-overlay';
import {KlCanvasWorkspace} from './canvas-ui/kl-canvas-workspace';
import {KlCanvasPreview} from './canvas-ui/canvas-preview';
import {FreeTransform} from './ui/components/free-transform';
import {FreeTransformCanvas} from './ui/components/free-transform-canvas';
import {Cropper} from './ui/components/cropper';
import {LayerPreview} from './ui/components/layer-preview';
import {showImportAsLayerDialog} from './ui/modals/show-import-as-layer-dialog';
import {KlImageDropper} from './ui/components/kl-image-dropper';
import {OverlayToolspace} from './ui/components/overlay-toolspace';
import {ToolspaceTopRow} from './ui/components/toolspace-top-row';
import {ToolDropdown} from './ui/components/tool-dropdown';
import {ToolspaceToolRow} from './ui/components/toolspace-tool-row';
import {ToolspaceStabilizerRow} from './ui/components/toolspace-stabilizer-row';
import {TabRow} from './ui/components/tab-row';
import {HandUi} from './ui/tool-tabs/hand-ui';
import {FillUi} from './ui/tool-tabs/fill-ui';
import {TextUi} from './ui/tool-tabs/text-ui';
import {ShapeUi} from './ui/tool-tabs/shape-ui';
import {newImageDialog} from './ui/modals/new-image-dialog';
import {ToolspaceCollapser} from './ui/components/toolspace-collapser';
import {renderText} from './image-operations/render-text';
import {textToolDialog} from './ui/modals/text-tool-dialog/text-tool-dialog';
import {showImportImageDialog} from './ui/modals/show-import-image-dialog';
import {floodFillBits} from './image-operations/flood-fill';
import * as PSD from './storage/psd';
import {drawShape, ShapeTool} from './image-operations/shape-tool';
import {KlCanvas} from './canvas/kl-canvas';
import * as indexedDb from './storage/indexed-db';
import {setDbName} from './storage/indexed-db';
import {filterLib, filterLibStatus} from './filters/filters';
import {brushes} from './brushes/brushes';
import {brushesUI} from './brushes-ui/brushes-ui';
import {showIframeModal} from './ui/modals/show-iframe-modal';
import {RadioList} from './ui/components/radio-list';
import {BrowserStorageUi} from './ui/components/browser-storage-ui';
import {drawProject} from './canvas/draw-project';
import {ProjectStore} from './storage/project-store';
import {FileTab} from './ui/tool-tabs/file-tab';
import {FilterTab} from './ui/tool-tabs/filter-tab';
import {imgurUpload} from './ui/modals/imgur-upload';
import {loadAgPsd} from './storage/load-ag-psd';
import {SaveReminder} from './ui/components/save-reminder';
import {SaveToComputer} from './storage/save-to-computer';
import {UndoRedoCatchup} from './history/undo-redo-catchup';
import {BrushSettingService} from './brushes-ui/brush-setting-service';
import {BoxToggle} from './ui/components/box-toggle';
import {SettingsTab} from './ui/tool-tabs/settings-tab';
import {ToolspaceScroller} from './ui/components/toolspace-scroller';
import {GradientUi} from './ui/tool-tabs/gradient-ui';
import {drawGradient, GradientTool} from './image-operations/gradient-tool';

/**
 * paint tool functionality
 */
export const KL = {

    // --- brushes ---
    brushes,
    brushesUI,
    BrushSettingService,

    // --- canvas ---
    KlCanvas,
    drawProject,

    // --- canvas ui ---
    WorkspaceSvgOverlay,
    KlCanvasWorkspace,
    KlCanvasPreview,

    // --- filters ---
    filterLibStatus,
    filterLib,

    // --- history ---
    UndoRedoCatchup,

    // --- image operations ---
    renderText,
    floodFillBits,
    ShapeTool,
    drawShape,
    GradientTool,
    drawGradient,

    // --- storage ---
    PSD,
    setDbName,
    indexedDb,
    ProjectStore,
    loadAgPsd,
    SaveToComputer,

    // --- ui - components ---
    calcSliderFalloffFactor,
    Checkbox,
    input,
    Select,
    ImageToggle,
    ImageRadioList,
    RadioList,
    createPenPressureToggle,
    KlSlider,
    HexColorDialog,
    KlColorSlider,
    KlColorSliderSmall,
    PointSlider,
    ColorOptions,
    Options,
    BoxToggle,
    StatusOverlay,
    CropCopy,
    FreeTransform,
    FreeTransformCanvas,
    Cropper,
    LayerPreview,
    KlImageDropper,
    OverlayToolspace,
    ToolspaceTopRow,
    ToolDropdown,
    ToolspaceToolRow,
    ToolspaceStabilizerRow,
    TabRow,
    ToolspaceCollapser,
    BrowserStorageUi,
    SaveReminder,
    ToolspaceScroller,

    // --- ui - modals ---
    dialogCounter,
    popup: showModal,
    Popup: DynamicModal,
    clipboardDialog,
    showImportAsLayerDialog,
    newImageDialog,
    textToolDialog,
    showImportImageDialog,
    showIframePopup: showIframeModal,
    imgurUpload,

    // --- ui - tool tabs ---
    HandUi,
    FillUi,
    GradientUi,
    TextUi,
    ShapeUi,
    FileTab,
    FilterTab,
    SettingsTab,
    LayerManager,

    klHistory,
    DecoyKlHistory,
};

Object.keys(KL); // without this, parcel build may break this object


code:
export function base64ToBlob (base64Str: string): Blob {
    const parts = base64Str.match(/data:([^;]*)(;base64)?,([0-9A-Za-z+/]+)/) as [string, string ,string, string];
    const binStr = atob(parts[3]);
    const buf = new ArrayBuffer(binStr.length);
    const view = new Uint8Array(buf);
    for (let i = 0; i < view.length; i++) {
        view[i] = binStr.charCodeAt(i);
    }
    return new Blob([view], {'type': parts[1]});
}


code:
import {IKlStorageProject} from '../kl-types';

const indexedDbIsSupported = !!window.indexedDB;
let dbNameStr = 'Klecks';
const storageNameStr = 'ProjectStore';

export function setDbName (name: string): void {
    dbNameStr = '' + name;
}

/**
 * connects to db dbNameStr, then executes transaction on storageNameStr storage
 * with index 'id'
 *
 * @param actionFunction what to execute during transaction
 * @param successCallback on successful transaction
 * @param errorCallback on error
 */
function execIndexedDBTransaction (
    actionFunction: (storeObj: IDBObjectStore) => void,
    successCallback: () => void,
    errorCallback: (errorStr: string) => void,
): void {

    let hasFinished = false;

    function onSuccess (): void {
        if (hasFinished) {
            return;
        }
        hasFinished = true;
        successCallback();
    }

    function onError (errorStr: string): void {
        if (hasFinished) {
            return;
        }
        hasFinished = true;
        errorCallback(errorStr);
    }

    if (!indexedDbIsSupported) {
        setTimeout(function () {
            onError('no indexed db available');
        }, 0);
        return;
    }
    let requestObj: IDBOpenDBRequest;

    try {
        requestObj = window.indexedDB.open(dbNameStr, 1);
    } catch (e) {
        onError((e as {message: string}).message);
        return;
    }

    requestObj.onupgradeneeded = function (): void {
        try {
            const db: IDBDatabase = requestObj.result;
            const store = db.createObjectStore(storageNameStr, {keyPath: 'id'});
            store.createIndex('id', 'id', {unique: true});
        } catch (e) {
            onError((e as {message: string}).message);
        }
    };
    requestObj.onerror = function (): void {
        onError('indexedDB.open failed, ' + requestObj.error);
    };
    requestObj.onsuccess = function (): void {
        let databaseObj: IDBDatabase;
        let transactionObj: IDBTransaction;
        let storeObj: IDBObjectStore;

        try {
            databaseObj = requestObj.result;
            if (!databaseObj.objectStoreNames.contains(storageNameStr)) {
                //someone maybe messed with the db, or creation failed earlier
                //it's broken -> destroy
                window.indexedDB.deleteDatabase(dbNameStr);
                onError('object store ' + storageNameStr + ' missing. destroying db');
                return;
            }
            transactionObj = databaseObj.transaction(storageNameStr, 'readwrite');
            storeObj = transactionObj.objectStore(storageNameStr);
            storeObj.index('id');
        } catch (e) {
            onError((e as {message: string}).message);
            return;
        }

        databaseObj.onerror = function (): void {
            onError('database error, ' + (databaseObj as IDBDatabase & {error: string}).error);
        };

        try {
            actionFunction(storeObj);
        } catch (e) {
            onError((e as {message: string}).message);
            return;
        }

        transactionObj.oncomplete = function (): void {
            onSuccess();
            databaseObj.close();
        };
        transactionObj.onerror = function (): void {
            onError('transaction error, ' + transactionObj.error);
        };
    };
}

export function getKlProjectObj (
    successCallback: (result: undefined | IKlStorageProject) => void,
    errorCallback: (error: string) => void,
): void {
    if (indexedDbIsSupported) {
        let query: IDBRequest<undefined | IKlStorageProject>;
        execIndexedDBTransaction(function (storeObj) {
            query = storeObj.get(1);
        }, function () {
            successCallback(query.result);
        }, function (errorStr) {
            errorCallback('execIndexedDBTransaction error, ' + errorStr);
        });
    } else {
        successCallback(undefined);
    }
}

/**
 * stores a project into id = 1 in database:dbNameStr > storage: storageNameStr
 */
export function storeKlProjectObj (
    storageProject: IKlStorageProject,
    successCallback: () => void,
    errorCallback: (error: string) => void,
): void {
    execIndexedDBTransaction(function (storeObj) {
        storeObj.put(storageProject);
    }, function () {
        successCallback();
    }, function (errorStr) {
        errorCallback(errorStr);
    });

}

/**
 * deletes stored project, by removing id = 1 from database:dbNameStr > storage: storageNameStr
 */
export function clear (
    successCallback: () => void,
    errorCallback: (error: string) => void,
): void {
    execIndexedDBTransaction(function (storeObj) {
        storeObj.delete(1);
    }, function () {
        successCallback();
    }, function (error) {
        errorCallback(error);
    });

}


code:
import {KL} from '../kl';
import {KlCanvas} from '../canvas/kl-canvas';
import {Psd} from 'ag-psd/dist/psd';
import {loadAgPsd} from './load-ag-psd';

export async function klCanvasToPsdBlob (klCanvas: KlCanvas): Promise<Blob> {
    const layerArr = klCanvas.getLayersFast();

    const psdConfig: Psd = {
        width: klCanvas.getWidth(),
        height: klCanvas.getHeight(),
        //canvas: klCanvas.getCompleteCanvas(1), // preview, can be skipped
        children: layerArr.map(item => {
            // todo - can be optimized if layer mostly empty
            return {
                name: item.name,
                hidden: !item.isVisible,
                opacity: item.opacity,
                canvas: item.canvas,
                blendMode: KL.PSD.blendKlToPsd(item.mixModeStr),
                left: 0,
                top: 0,
            };
        }),
    };

    const agPsd = await loadAgPsd();
    const buffer = agPsd.writePsdBuffer(psdConfig);
    return new Blob([buffer], { type: 'application/octet-stream' });
}


code:
export type TAgPsd = Awaited<typeof import('ag-psd')>;
let agPsdLazy: TAgPsd;

export async function loadAgPsd (): Promise<TAgPsd> {
    if (!agPsdLazy) {
        agPsdLazy = await import('ag-psd');
    }
    return agPsdLazy;
}


code:
import {IKlProject, IKlStorageProject} from '../kl-types';
import {BB} from '../../bb/bb';
import {drawProject} from '../canvas/draw-project';
import {base64ToBlob} from './base-64-to-blob';

export type TReadStorageProjectResult = {
    project: IKlProject;
    timestamp: number;
    thumbnail: HTMLImageElement | HTMLCanvasElement;
};

const thumbSize = 240;

function loadImage (blob: Blob): Promise<HTMLImageElement> {
    return new Promise<HTMLImageElement>((resolve, reject) => {
        const im = new Image();
        try {
            im.src = BB.imageBlobToUrl(blob);
        } catch (e) {
            reject('imageBlobToUrl, ' + (e instanceof Error ? e.message : ''));
            return;
        }
        im.onload = (): void => {
            URL.revokeObjectURL(im.src);
            resolve(im);
        };
        im.onabort = (): void => {
            reject('layer image failed loading');
        };
        im.onerror = (): void => {
            reject('layer image failed loading');
        };
    });
}

/**
 * for:
 * - preparing project to be stored in ProjectStore
 * - reading a project that came out of the ProjectStore
 */
export class ProjectConverter {

    private static createThumbnail (project: IKlProject): HTMLCanvasElement {
        const size = BB.fitInto(project.width, project.height, thumbSize, thumbSize);
        const factor = size.width / project.width;
        return drawProject(project, factor);
    }

    static createStorageProject (project: IKlProject): IKlStorageProject {
        return {
            id: 1,
            timestamp: new Date().getTime(),
            thumbnail: base64ToBlob(ProjectConverter.createThumbnail(project).toDataURL('image/png')),
            width: project.width,
            height: project.height,
            layers: project.layers.map(item => {
                let blob;
                if (item.image instanceof HTMLCanvasElement) {
                    blob = base64ToBlob((item.image as HTMLCanvasElement).toDataURL('image/png'));
                } else {
                    // todo image
                    throw new Error('Not implemented');
                }
                return {
                    name: item.name,
                    isVisible: item.isVisible,
                    opacity: item.opacity,
                    mixModeStr: item.mixModeStr,
                    blob,
                };
            }),
        };
    }

    static async readStorageProject (storageProject: IKlStorageProject): Promise<TReadStorageProjectResult> {
        if (
            !storageProject.width || !storageProject.height ||
            isNaN(storageProject.width) || isNaN(storageProject.height) ||
            storageProject.width < 1 || storageProject.height < 1
        ) {
            throw new Error('readStorageProject invalid canvas size: ' + storageProject.width + ', ' + storageProject.height);
        }
        const project: IKlProject = {
            width: storageProject.width,
            height: storageProject.height,
            layers: (await Promise.all(storageProject.layers.map(layer => loadImage(layer.blob))))
                .map((image, i) => {
                    const storageLayer = storageProject.layers[i];
                    return {
                        name: storageLayer.name,
                        isVisible: 'isVisible' in storageLayer ? storageLayer.isVisible : true, // isVisible added 2023-07
                        opacity: storageLayer.opacity,
                        mixModeStr: storageLayer.mixModeStr,
                        image,
                    };
                }),
        };

        let thumbnail;
        if (storageProject.thumbnail) {
            thumbnail = await loadImage(storageProject.thumbnail);
        } else {
            thumbnail = ProjectConverter.createThumbnail(project);
        }

        return {
            project: project,
            timestamp: storageProject.timestamp,
            thumbnail: thumbnail,
        };
    }

}


code:
import {IKlProject, IKlStorageProject} from '../kl-types';
import {ProjectConverter, TReadStorageProjectResult} from './project-converter';
import {clear, getKlProjectObj, storeKlProjectObj} from './indexed-db';
import {LocalStorage} from '../../bb/base/local-storage';

export interface IProjectStoreListener {
    onUpdate: (timestamp?: number, thumbnail?: HTMLImageElement | HTMLCanvasElement) => void;
}

function makeAsync<T> (
    nonAsyncFunc: (
        resolveCallback: (value: T) => void,
        rejectCallback: (reason?: unknown) => void
    ) => void,
): Promise<T> {
    return new Promise<T>((resolve, reject) => {
        nonAsyncFunc(resolve, reject);
    });
}

/**
 * simplified interface for storing projects into browser storage
 */
export class ProjectStore {

    private listeners: IProjectStoreListener[] = [];
    private accessHasFailed: boolean = false;

    async lowLevelStore (project: IKlStorageProject): Promise<void> {
        await new Promise<void>((resolve, reject) => {
            storeKlProjectObj(project, resolve, reject);
        });
    }

    async lowLevelRead (): Promise<IKlStorageProject> {
        return await makeAsync(getKlProjectObj) as IKlStorageProject;
    }

    async lowLevelClear (): Promise<void> {
        await makeAsync<void>(clear);
    }

    private emit (timestamp?: number, thumbnail?: HTMLImageElement | HTMLCanvasElement): void {
        this.listeners.forEach(item => {
            item.onUpdate(timestamp, thumbnail);
        });
    }

    private updateTimestamp (): void {
        LocalStorage.setItem('indexedDbUpdatedAt', '' + new Date().getTime());
    }

    // --- public ---

    constructor () {
        window.addEventListener('storage', (e) => {
            if (e.key !== 'indexedDbUpdatedAt' || this.listeners.length === 0) {
                return;
            }
            try {
                ( async (): Promise<void> => {
                    const readResult = await this.read();
                    if (readResult) {
                        this.emit(readResult.timestamp, readResult.thumbnail);
                    } else {
                        this.emit();
                    }
                })();
            } catch (e) {
                if (e instanceof Error && e.message.indexOf('db-error') === 0) {
                    this.accessHasFailed = true;
                }
            }

        });
    }

    async read (): Promise<{
        project: IKlProject;
        timestamp: number;
        thumbnail: HTMLImageElement | HTMLCanvasElement;
    } | undefined> {
        let storageProject;
        try {
            storageProject = await this.lowLevelRead();
        } catch (e) {
            this.accessHasFailed = true;
            throw new Error('db-error: ' + e);
        }
        if (!storageProject) {
            return undefined;
        }
        let result;
        try {
            result = await ProjectConverter.readStorageProject(storageProject);
        } catch (e) {
            throw new Error('format-error: ' + e);
        }
        return result;
    }

    async store (project: IKlProject): Promise<void> {
        try {
            const storageProject = ProjectConverter.createStorageProject(project);
            await this.lowLevelStore(storageProject);
        } catch (e) {
            this.accessHasFailed = true;
            throw new Error('db-error: ' + e);
        }
        {
            // immediately test if it can be read
            const storageProject = await this.lowLevelRead();
            let readResult: TReadStorageProjectResult;
            try {
                readResult = await ProjectConverter.readStorageProject(storageProject);
            } catch (e) {
                await this.lowLevelClear();
                this.updateTimestamp();
                setTimeout(() => this.emit(), 0);
                throw new Error('format-error: ' + e);
            }
            this.updateTimestamp();
            setTimeout(() => this.emit(readResult.timestamp, readResult.thumbnail), 0);
        }
    }

    async clear (): Promise<void> {
        await this.lowLevelClear();
        this.updateTimestamp();
        setTimeout(() => this.emit(), 0);
    }

    subscribe (listener: IProjectStoreListener): void {
        if (this.listeners.includes(listener)) {
            return;
        }
        this.listeners.push(listener);
    }

    unsubscribe (listener: IProjectStoreListener): void {
        for (let i = 0; i < this.listeners.length; i++) {
            if (listener === this.listeners[i]) {
                this.listeners.splice(i, 1);
                return;
            }
        }
    }

    isBroken (): boolean {
        return this.accessHasFailed;
    }

}


code:
import {createCanvas} from '../../bb/base/create-canvas';
import {BlendMode, Layer, Psd} from 'ag-psd/dist/psd';
import {IKlProject, IKlPsd, TKlPsdError, TKlPsdLayer, TMixMode} from '../kl-types';
import {LANG} from '../../language/language';
import {MAX_LAYERS} from '../canvas/kl-canvas';
import {BB} from '../../bb/bb';
import {throwIfUndefined} from '../../bb/base/base';

let kl2PsdMap: Record<TMixMode, BlendMode>;
let psd2KlMap: Record<BlendMode, TMixMode>;

function init (): void {
    if (kl2PsdMap) {
        return;
    }
    kl2PsdMap = {
        'source-over': 'normal',

        'darken': 'darken',
        'multiply': 'multiply',
        'color-burn': 'color burn',

        'lighten': 'lighten',
        'screen': 'screen',
        'color-dodge': 'color dodge',

        'overlay': 'overlay',
        'soft-light': 'soft light',
        'hard-light': 'hard light',

        'difference': 'difference',
        'exclusion': 'exclusion',

        'hue': 'hue',
        'saturation': 'saturation',
        'color': 'color',
        'luminosity': 'luminosity',
    };
    psd2KlMap = Object.fromEntries(Object.entries(kl2PsdMap).map(a => a.reverse()));
}

export function blendPsdToKl (str: BlendMode): TMixMode {
    init();
    return psd2KlMap[str];
}

export function blendKlToPsd (str: TMixMode): BlendMode {
    init();
    return kl2PsdMap[str];
}

/**
 * Converts ag-psd object into something that KlCanvas can represent
 * @param psdObj
 */
export function readPsd (psdObj: Psd): IKlPsd {
    if (!psdObj.canvas) {
        throw new Error('psdObj.canvas undefined');
    }
    const result: IKlPsd = {
        type: 'psd',
        canvas: psdObj.canvas,
        width: psdObj.width,
        height: psdObj.height,
    };

    function addWarning (warningStr: TKlPsdError): void {
        if (!result.warningArr) {
            result.warningArr = [];
        }
        if (result.warningArr.includes(warningStr)) {
            return;
        }
        result.warningArr.push(warningStr);
    }

    function getMixModeStr (blendMode: BlendMode): TMixMode {
        let mixModeStr: TMixMode = blendPsdToKl(blendMode);
        if (!mixModeStr) {
            addWarning('blend-mode');
            mixModeStr = 'source-over';
        }
        return mixModeStr;
    }

    if (psdObj.bitsPerChannel !== 8) {
        addWarning('bits-per-channel');
    }

    if (!psdObj.children) {
        result.error = true;
        return result;
    }

    // count resulting layers
    const maxLayers = MAX_LAYERS;
    let layerCount = 0;
    function countWithinGroup (groupObj: Layer): number {
        let result = 0;
        if (groupObj.blendMode) {
            const mixModeStr = blendPsdToKl(groupObj.blendMode);
            if (mixModeStr && mixModeStr !== 'source-over') {
                return 1;
            }
        }
        if (groupObj.children) {
            for (let i = 0; i < groupObj.children.length; i++) {
                const item = groupObj.children[i];
                if (item.clipping || item.adjustment) {
                    continue;
                }

                if (item.children) {
                    addWarning('group');
                    result += countWithinGroup(item);
                } else {
                    result++;
                }
            }
        }
        return result;
    }
    layerCount += countWithinGroup(psdObj);
    if (layerCount > maxLayers) {
        result.error = true;
        return result;
    }

    result.layers = [];

    function prepareMask (maskCanvas: HTMLCanvasElement, defaultColor: number): void {
        const groupMaskCtx = BB.ctx(maskCanvas);
        const imData = groupMaskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
        if (defaultColor === 0) {
            for (let i = 0; i < imData.data.length; i += 4) {
                imData.data[i + 3] = imData.data[i];
            }
        } else {
            for (let i = 0; i < imData.data.length; i += 4) {
                imData.data[i + 3] = 255 - imData.data[i];
            }
        }
        groupMaskCtx.putImageData(imData, 0, 0);
    }

    function convertGroup (psdGroupObj: Layer): TKlPsdLayer[] {

        let resultArr: TKlPsdLayer[] = [];
        const groupIsVisible = !psdGroupObj.hidden;
        const groupOpacity = throwIfUndefined(psdGroupObj.opacity, 'groupOpacity is undefined');
        const groupMixModeStr = getMixModeStr(psdGroupObj.blendMode!);
        let groupCanvas;
        let groupCtx;
        if (groupMixModeStr !== 'source-over') {
            groupCanvas = createCanvas(result.width, result.height);
            groupCtx = BB.ctx(groupCanvas);
        }

        // prepare group mask
        if (psdGroupObj.mask) {
            addWarning('mask');
            prepareMask(psdGroupObj.mask.canvas!, psdGroupObj.mask.defaultColor!);
        }

        if (psdGroupObj.children) {
            for (let i = 0; i < psdGroupObj.children.length; i++) {
                const item = psdGroupObj.children[i];
                if (item.clipping) {
                    continue;
                }
                if (item.adjustment) {
                    addWarning('adjustment');
                    continue;
                }

                const hasClipping = (item.children || item.canvas) && psdGroupObj.children[i + 1] && psdGroupObj.children[i + 1].clipping;
                if (hasClipping) {
                    addWarning('clipping');
                }

                if (item.children) {
                    const convertedChildGroupItems = convertGroup(item);

                    for (let e = 0; e < convertedChildGroupItems.length; e++) {
                        const innerItem = convertedChildGroupItems[e];
                        const innerCtx = BB.ctx(innerItem.image);

                        // clipping
                        if (hasClipping) {
                            const clippingCanvas = createCanvas(result.width, result.height);
                            const clippingCtx = BB.ctx(clippingCanvas);
                            clippingCtx.drawImage(innerItem.image, 0, 0);

                            for (let f = i + 1; f < psdGroupObj.children.length && psdGroupObj.children[f].clipping; f++) {
                                const clippingItem = psdGroupObj.children[f];
                                if (clippingItem.opacity === 0 || clippingItem.hidden) {
                                    continue;
                                }
                                if (clippingItem.blendMode === undefined) {
                                    throw new Error('clippingItem.blendMode undefined');
                                }
                                if (clippingItem.opacity === undefined) {
                                    throw new Error('clippingItem.opacity undefined');
                                }
                                if (clippingItem.canvas === undefined) {
                                    throw new Error('clippingItem.canvas undefined');
                                }
                                if (clippingItem.left === undefined) {
                                    throw new Error('clippingItem.left undefined');
                                }
                                if (clippingItem.top === undefined) {
                                    throw new Error('clippingItem.top undefined');
                                }
                                clippingCtx.globalCompositeOperation = getMixModeStr(clippingItem.blendMode);
                                clippingCtx.globalAlpha = clippingItem.opacity;
                                clippingCtx.drawImage(clippingItem.canvas, clippingItem.left, clippingItem.top);
                            }

                            innerCtx.globalCompositeOperation = 'source-atop';
                            innerCtx.drawImage(clippingCanvas, 0, 0);
                        }

                        // group mask
                        if (psdGroupObj.mask) {
                            innerCtx.globalCompositeOperation = psdGroupObj.mask.defaultColor === 0 ? 'destination-in' : 'destination-out';
                            if (psdGroupObj.mask.canvas === undefined) {
                                throw new Error('psdGroupObj.mask.canvas undefined');
                            }
                            if (psdGroupObj.mask.left === undefined) {
                                throw new Error('psdGroupObj.mask.left undefined');
                            }
                            if (psdGroupObj.mask.top === undefined) {
                                throw new Error('psdGroupObj.mask.top undefined');
                            }
                            innerCtx.drawImage(psdGroupObj.mask.canvas, psdGroupObj.mask.left, psdGroupObj.mask.top);
                        }

                        if (groupCanvas) {
                            if (groupCtx === undefined) {
                                throw new Error('groupCtx undefined');
                            }
                            groupCtx.globalCompositeOperation = innerItem.mixModeStr;
                            groupCtx.globalAlpha = innerItem.opacity;
                            groupCtx.drawImage(innerItem.image, 0, 0);

                        } else {
                            innerItem.opacity = innerItem.opacity * groupOpacity;
                            resultArr.push(innerItem);
                        }
                    }

                    continue;
                }

                const canvas = createCanvas(result.width, result.height);
                const ctx = BB.ctx(canvas);
                if (item.canvas) { // if a layer is empty it has no canvas
                    if (item.top === undefined) {
                        throw new Error('item.top undefined');
                    }
                    if (item.left === undefined) {
                        throw new Error('item.left undefined');
                    }
                    ctx.drawImage(item.canvas, item.left, item.top);
                }

                // effects
                if (item.effects) {
                    addWarning('layer-effect');
                }

                // mask
                if (item.mask) {
                    addWarning('mask');
                    if (item.mask.canvas === undefined) {
                        throw new Error('item.mask.canvas undefined');
                    }
                    if (item.mask.defaultColor === undefined) {
                        throw new Error('item.mask.defaultColor undefined');
                    }
                    if (item.mask.left === undefined) {
                        throw new Error('item.mask.left undefined');
                    }
                    if (item.mask.top === undefined) {
                        throw new Error('item.mask.top undefined');
                    }
                    prepareMask(item.mask.canvas, item.mask.defaultColor);

                    ctx.globalCompositeOperation = item.mask.defaultColor === 0 ? 'destination-in' : 'destination-out';
                    ctx.drawImage(item.mask.canvas, item.mask.left, item.mask.top);
                }

                // clipping
                if (hasClipping) {
                    if (item.right === undefined) {
                        throw new Error('item.right undefined');
                    }
                    if (item.left === undefined) {
                        throw new Error('item.left undefined');
                    }
                    if (item.bottom === undefined) {
                        throw new Error('item.bottom undefined');
                    }
                    if (item.top === undefined) {
                        throw new Error('item.top undefined');
                    }
                    if (item.canvas === undefined) {
                        throw new Error('item.canvas undefined');
                    }
                    const clippingCanvas = createCanvas(item.right - item.left, item.bottom - item.top);
                    const clippingCtx = BB.ctx(clippingCanvas);
                    clippingCtx.drawImage(item.canvas, 0, 0);

                    for (let e = i + 1; e < psdGroupObj.children.length && psdGroupObj.children[e].clipping; e++) {
                        const clippingItem = psdGroupObj.children[e];
                        if (clippingItem.opacity === 0 || clippingItem.hidden) {
                            continue;
                        }
                        if (clippingItem.blendMode === undefined) {
                            throw new Error('clippingItem.blendMode undefined');
                        }
                        if (clippingItem.opacity === undefined) {
                            throw new Error('clippingItem.opacity undefined');
                        }
                        if (clippingItem.canvas === undefined) {
                            throw new Error('clippingItem.canvas undefined');
                        }
                        if (clippingItem.left === undefined) {
                            throw new Error('clippingItem.left undefined');
                        }
                        if (clippingItem.top === undefined) {
                            throw new Error('clippingItem.top undefined');
                        }
                        clippingCtx.globalCompositeOperation = getMixModeStr(clippingItem.blendMode);
                        clippingCtx.globalAlpha = clippingItem.opacity;
                        clippingCtx.drawImage(clippingItem.canvas, clippingItem.left - item.left, clippingItem.top - item.top);
                    }

                    ctx.globalCompositeOperation = 'source-atop';
                    ctx.drawImage(clippingCanvas, item.left, item.top);
                }

                // group mask
                if (psdGroupObj.mask) {
                    ctx.globalCompositeOperation = psdGroupObj.mask.defaultColor === 0 ? 'destination-in' : 'destination-out';
                    if (psdGroupObj.mask.canvas === undefined) {
                        throw new Error('psdGroupObj.mask.canvas undefined');
                    }
                    if (psdGroupObj.mask.left === undefined) {
                        throw new Error('psdGroupObj.mask.left undefined');
                    }
                    if (psdGroupObj.mask.top === undefined) {
                        throw new Error('psdGroupObj.mask.top undefined');
                    }
                    ctx.drawImage(psdGroupObj.mask.canvas, psdGroupObj.mask.left, psdGroupObj.mask.top);
                }

                if (item.blendMode === undefined) {
                    throw new Error('item.blendMode undefined');
                }
                if (item.hidden  === undefined) {
                    throw new Error('item.hidden  undefined');
                }
                if (item.opacity === undefined) {
                    throw new Error('item.opacity undefined');
                }

                if (groupCanvas && groupCtx) {
                    if (groupOpacity > 0) {
                        groupCtx.globalCompositeOperation = getMixModeStr(item.blendMode);
                        groupCtx.globalAlpha = item.hidden ? 0 : item.opacity;
                        groupCtx.drawImage(canvas, 0, 0);
                    }

                } else {
                    if (item.name === undefined) {
                        throw new Error('item.name undefined');
                    }
                    resultArr.push({
                        name: item.name,
                        isVisible: !item.hidden && groupIsVisible,
                        opacity: item.opacity * groupOpacity,
                        mixModeStr: getMixModeStr(item.blendMode),
                        image: canvas,
                    });
                }
            }
        }

        if (groupCanvas) {
            if (psdGroupObj.name === undefined) {
                throw new Error('psdGroupObj.name undefined');
            }
            resultArr = [
                {
                    name: psdGroupObj.name,
                    isVisible: groupIsVisible,
                    opacity: groupOpacity,
                    mixModeStr: groupMixModeStr,
                    image: groupCanvas,
                },
            ];
        }

        return resultArr;
    }

    result.layers = convertGroup({
        name: 'root',
        opacity: 1,
        blendMode: 'normal',
        children: psdObj.children,
    });

    return result;
}

export function klPsdToKlProject (klPsd: IKlPsd): IKlProject {
    // only share references to Canvas elements
    const result: IKlProject = {
        width: klPsd.width,
        height: klPsd.height,
        layers: [],
    };
    if (klPsd.layers) {
        result.layers = klPsd.layers.map(item => {
            return {
                name: item.name,
                isVisible: item.isVisible,
                opacity: item.opacity,
                mixModeStr: item.mixModeStr,
                image: item.image,
            };
        });
    } else {
        // flattened
        result.layers = [{
            name: LANG('background'),
            isVisible: true,
            opacity: 1,
            mixModeStr: 'source-over',
            image: klPsd.canvas,
        }];
    }
    return result;
}


code:
import {BB} from '../../bb/bb';
import {KL} from '../kl';
import {KlCanvas} from '../canvas/kl-canvas';
import {TExportType} from '../kl-types';
import {SaveReminder} from '../ui/components/save-reminder';
import {saveAs} from '../../bb/base/save-as';
import {Psd} from 'ag-psd/dist/psd';
import {klConfig} from '../kl-config';

export class SaveToComputer {

    /**
     * Using old code, because saving somehow doesn't work for ipad before ios 13,
     * and it doesn't even throw an exception.
     */
    private saveImage (canvas: HTMLCanvasElement, filename: string, mimeType: string): void {
        const parts = canvas.toDataURL(mimeType).match(/data:([^;]*)(;base64)?,([0-9A-Za-z+/]+)/);

        if (!parts) {
            throw new Error('saveImage: empty parts');
        }

        //assume base64 encoding
        const binStr = atob(parts[3]);
        //convert to binary in ArrayBuffer
        const buf = new ArrayBuffer(binStr.length);
        const view = new Uint8Array(buf);
        for (let i = 0; i < view.length; i++) {
            view[i] = binStr.charCodeAt(i);
        }
        const blob = new Blob([view], {'type': parts[1]});
        saveAs(blob, filename);
    }

    constructor (
        private saveReminder: SaveReminder,
        private getExportType: () => TExportType,
        private getKlCanvas: () => KlCanvas,
    ) {}

    save (format?: 'psd' | 'layers' | 'png'): void {
        this.saveReminder.reset();

        if (!format) {
            format = this.getExportType();
        }

        if (format === 'png') {
            const extension = 'png';
            const mimeType = 'image/png';
            const filename = BB.getDate() + klConfig.filenameBase + '.' + extension;
            const fullCanvas = this.getKlCanvas().getCompleteCanvas(1);
            try {
                this.saveImage(fullCanvas, filename, mimeType);
            } catch (error) { //fallback for old browsers
                alert('could not save');
                throw new Error('failed png export');
            }
        } else if (format === 'layers') {
            const extension = 'png';
            const mimeType = 'image/png';
            const fileBase = BB.getDate() + klConfig.filenameBase;
            const layerArr = this.getKlCanvas().getLayersFast();
            for (let i = 0; i < layerArr.length; i++) {
                const item = layerArr[i];
                const fnameArr = [
                    fileBase,
                    '_',
                    ('' + (i + 1)).padStart(2, '0'),
                    '_',
                    item.name,
                    '.',
                    extension,
                ];
                this.saveImage(item.canvas, fnameArr.join(''), mimeType);
            }
        } else if (format === 'psd') {

            const layerArr = this.getKlCanvas().getLayersFast();

            const psdConfig: Psd = {
                width: this.getKlCanvas().getWidth(),
                height: this.getKlCanvas().getHeight(),
                children: [],
                canvas: this.getKlCanvas().getCompleteCanvas(1),
            };
            for (let i = 0; i < layerArr.length; i++) {
                const item = layerArr[i];
                psdConfig.children!.push({
                    name: item.name,
                    hidden: !item.isVisible,
                    opacity: item.opacity,
                    canvas: item.canvas,
                    blendMode: KL.PSD.blendKlToPsd(item.mixModeStr),
                    left: 0,
                    top: 0,
                });
            }

            KL.loadAgPsd().then((agPsdLazy) => {
                const buffer = agPsdLazy.writePsdBuffer(psdConfig);
                const blob = new Blob([buffer], { type: 'application/octet-stream' });
                saveAs(blob, BB.getDate() + klConfig.filenameBase + '.psd');
            }).catch(() => {
                alert('Error: failed to load PSD library');
            });

        }

    }

}


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

export class BoxToggle {
    el: HTMLElement;
    value: boolean;

    update (): void {
        this.el.classList.toggle('kl-box-toggle--active', this.value);
    }

    // --- public ---
    constructor (
        p: {
            label: string | HTMLElement | SVGElement;
            title?: string;
            init?: boolean;
            onChange: (b: boolean) => void;
        }
    ) {
        this.value = !!p.init;
        this.el = BB.el({
            content: p.label,
            title: p.title,
            className: (typeof p.label === 'string') ? 'kl-box-toggle' : 'kl-box-toggle kl-box-toggle--custom-el',
            onClick: () => {
                this.value = !this.value;
                this.update();
                p.onChange(this.value);
            },
            css: {
                cursor: 'pointer',
            },
        });
        if (typeof p.label !== 'string') {
            BB.css(p.label, {
                display: 'block',
                pointerEvents: 'none',
            });
        }
        this.update();
    }

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

    setValue (b: boolean): void {
        this.value = !!b;
        this.update();
    }

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

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


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

import removeLayerImg from '/src/app/img/ui/remove-layer.svg';
import {IKlProject} from '../../kl-types';
import {showIframeModal} from '../modals/show-iframe-modal';
import {SaveReminder} from './save-reminder';
import {IProjectStoreListener, ProjectStore} from '../../storage/project-store';
import {KL} from '../../kl';
import {LANG} from '../../../language/language';
import {theme} from '../../../theme/theme';

export class BrowserStorageUi {

    private previewEl: HTMLDivElement = {} as HTMLDivElement;
    private readonly infoEl: HTMLElement = {} as HTMLElement;
    private readonly ageEl: HTMLElement = {} as HTMLElement;
    private readonly storeButtonEl: HTMLButtonElement = {} as HTMLButtonElement;
    private readonly clearButtonEl: HTMLButtonElement = {} as HTMLButtonElement;
    private readonly storeListener: IProjectStoreListener = {} as IProjectStoreListener;
    private readonly updateCheckerboard: () => void = () => {};

    private timestamp: number | undefined;
    private thumbnail: HTMLImageElement | HTMLCanvasElement | undefined;

    private updateAge () {
        if (!this.timestamp) {
            return;
        }
        let age = new Date().getTime() - this.timestamp;
        let ageStr;
        age = Math.floor(age / 1000 / 60);
        ageStr = LANG('file-storage-min-ago').replace('{x}', '' + age);
        if (age > 60) {
            age = Math.floor(age / 60);
            ageStr = LANG('file-storage-hours-ago').replace('{x}', '' + age);
            if (age > 24) {
                age = Math.floor(age / 24);
                ageStr = LANG('file-storage-days-ago').replace('{x}', '' + age);
                if (age > 31) {
                    ageStr = LANG('file-storage-month-ago');
                }
            }
        }
        this.ageEl.textContent = ageStr;
    }

    private resetButtons () {
        if (this.timestamp) {
            this.storeButtonEl.textContent = LANG('file-storage-overwrite');
            this.storeButtonEl.disabled = false;
            this.clearButtonEl.disabled = false;
        } else {
            this.storeButtonEl.textContent = LANG('file-storage-store');
            this.storeButtonEl.disabled = false;
            this.clearButtonEl.disabled = true;
        }
    }

    private updateThumb (timestamp?: number, thumbnail?: HTMLImageElement | HTMLCanvasElement) {
        this.timestamp = timestamp;
        this.thumbnail = thumbnail;
        if (this.timestamp && thumbnail) {
            thumbnail.classList.add('kl-storage-preview__im');
            this.previewEl.innerHTML = '';
            this.updateAge();
            this.previewEl.append(thumbnail, this.ageEl);
        } else {
            this.previewEl.innerHTML = LANG('file-storage-empty');
        }
        this.resetButtons();
    }

    private async store () {
        this.storeButtonEl.textContent = LANG('file-storage-storing');
        this.storeButtonEl.disabled = true;
        this.clearButtonEl.disabled = true;
        await new Promise((resolve) => {
            setTimeout(() => resolve(null), 20);
        });
        try {
            await this.projectStore.store(this.getProject());
            this.saveReminder.reset();
        } catch (e) {
            this.resetButtons();
            KL.popup({
                target: this.klRootEl,
                type: 'error',
                message: [
                    `${LANG('file-storage-failed-1')}<ul>`,
                    `<li>${LANG('file-storage-failed-2')}</li>`,
                    `<li>${LANG('file-storage-failed-3')}</li>`,
                    `<li>${LANG('file-storage-failed-4')}</li>`,
                    '
',
                ].join(''),
                buttons: ['Ok'],
            });
            setTimeout(() => {
                throw new Error('storage-ui: failed to store browser storage, ' + e);
            }, 0);
        }
    }

    private async clear () {
        this.storeButtonEl.disabled = true;
        this.clearButtonEl.disabled = true;
        try {
            await this.projectStore.clear();
        } catch (e) {
            this.resetButtons();
            KL.popup({
                target: this.klRootEl,
                type: 'error',
                message: LANG('file-storage-failed-clear'),
                buttons: ['Ok'],
            });
            setTimeout(() => {
                throw new Error('storage-ui: failed to clear browser storage, ' + e);
            }, 0);
        }
    }

    element: HTMLDivElement;

    constructor (
        private projectStore: ProjectStore,
        private getProject: () => IKlProject,
        private saveReminder: SaveReminder,
        private klRootEl: HTMLElement,
        private options?: { hideClearButton?: boolean; isFocusable?: boolean }, // isFocusable default = false
    ) {
        this.element = BB.el({
            css: {
                display: 'grid',
                gridTemplateColumns: '1fr 0fr',
                gridTemplateRows: '0fr 0fr 0fr',
                gap: '0 0',
                gridTemplateAreas: '"title title" "preview store" "preview clear"',
            },
        });

        const title = BB.el({
            parent: this.element,
            content: LANG('file-storage'),
            css: {
                gridArea: 'title',
                display: 'flex',
                margin: '-5px 0',
                //background: '#f00',
            },
        });

        this.infoEl = BB.el({
            parent: title,
            content: '?',
            className: 'kl-storage-about',
            title: LANG('file-storage-about'),
            onClick: () => {
                showIframeModal('./help/#help-browser-storage', false);
            },
        });

        if (this.projectStore.isBroken()) {
            BB.el({
                parent: this.element,
                content: '🔴 ' + LANG('file-storage-cant-access'),
                css: {
                    marginTop: '10px',
                },
            });
            return;
        }

        this.previewEl = BB.el({
            parent: this.element,
            title: LANG('file-storage-thumb-title'),
            className: 'kl-storage-preview',
        });
        this.ageEl = BB.el({
            css: {
                position: 'absolute',
                right: '1px',
                bottom: '1px',
                width: 'calc(100% - 2px)',
                textAlign: 'center',
                background: 'rgba(0,0,0,0.7)',
                color: '#fff',
                textSize: '13px',
            },
        });
        const btnCustom: { [key: string]: string } = options?.isFocusable ?  {} : {
            tabIndex: '-1',
        };
        this.storeButtonEl = BB.el({
            parent: this.element,
            tagName: 'button',
            className: 'grid-button',
            content: LANG('file-storage-store'),
            css: {
                gridArea: 'store',
                //background: '#00f',
            },
            custom: btnCustom,
            onClick: () => this.store(),
        });
        this.clearButtonEl = BB.el({
            parent: this.element,
            tagName: 'button',
            className: 'grid-button',
            content: ' ' + LANG('file-storage-clear'),
            css: {
                gridArea: 'clear',
                //background: '#ff0',
            },
            custom: btnCustom,
            onClick: () => this.clear(),
        });

        if (this.options?.hideClearButton) {
            this.clearButtonEl.style.visibility = 'hidden';
        }

        this.storeListener = {
            onUpdate: (timestamp, thumbnail) => {
                this.updateThumb(timestamp, thumbnail);
            },
        };
        this.projectStore.subscribe(this.storeListener);

        this.updateCheckerboard = (): void => {
            if (!this.thumbnail) {
                return;
            }
            BB.css(this.thumbnail, {
                background: "url('" + BB.createCheckerCanvas(4, theme.isDark()).toDataURL('image/png') + "')",
            });
        };
        theme.addIsDarkListener(this.updateCheckerboard);

        setInterval(() => this.updateAge(), 1000 * 60);

        (async () => {
            try {
                const readResult = await this.projectStore.read();
                if (readResult) {
                    this.updateThumb(readResult.timestamp, readResult.thumbnail);
                } else {
                    this.updateThumb();
                }
            } catch (e) {
                setTimeout(() => {
                    throw new Error('storage-ui: failed initial read browser storage, ' + e);
                }, 0);
            }
        })();
    }

    getElement () {
        return this.element;
    }

    show () {
        // todo
    }

    hide () {
        // todo
    }

    destroy () {
        BB.destroyEl(this.infoEl);
        BB.destroyEl(this.storeButtonEl);
        BB.destroyEl(this.clearButtonEl);
        theme.removeIsDarkListener(this.updateCheckerboard);
        this.projectStore.unsubscribe(this.storeListener);
    }
}


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

/**
 * Checkbox - with label
 */
export class Checkbox {

    private readonly element: HTMLElement;
    private check: HTMLInputElement;
    private readonly doHighlight: boolean;

    // --- public ---

    constructor (params: {
        init?: boolean; // default false
        label: string;
        callback?: (b: boolean) => void;
        allowTab?: boolean; // default false
        title?: string;
        doHighlight?: boolean; // default false
        css?: IKeyStringOptional;
    }) {
        this.doHighlight = !!params.doHighlight;

        this.element = BB.el({
            className: 'kl-checkbox',
        });

        const innerEl = BB.el({
            parent: this.element,
            tagName: 'label',
            className: 'kl-checkbox__inner',
            css: {
                display: 'flex',
            },
        });

        this.check = BB.el({
            parent: innerEl,
            tagName: 'input',
            css: {
                margin: '0 5px 0 0',
            },
            custom: {
                type: 'checkbox',
            },
        });

        this.check.checked = !!params.init;
        if (this.doHighlight && this.check.checked) {
            this.element.classList.add('kl-checkbox--highlight');
        }
        if (!params.allowTab) {
            this.check.tabIndex = -1;
        }

        if (params.title) {
            innerEl.title = params.title;
        }

        const label = BB.el({
            parent: innerEl,
            content: params.label,
            css: {},
        });
        (label as any).allowClick = true;

        this.check.onchange = () => {
            if (this.doHighlight) {
                this.element.classList.toggle('kl-checkbox--highlight', this.check.checked);
            }
            params.callback && params.callback(this.check.checked);
            setTimeout(() => {
                this.check.blur();
            }, 0);
        };
        if (params.css) {
            BB.css(this.element, params.css);
        }
    }

    getValue (): boolean {
        return this.check.checked;
    }

    setValue (b: boolean): void {
        this.check.checked = !!b;
        if (this.doHighlight) {
            this.element.classList.toggle('kl-checkbox--highlight', this.check.checked);
        }
    }

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

    destroy (): void {
        this.check.onchange = null;
    }

}


code:
import {BB} from '../../../bb/bb';
import {IRGBA} from '../../kl-types';
import {theme} from '../../../theme/theme';
import {ColorConverter} from '../../../bb/color/color';
import {c} from '../../../bb/base/c';

/**
 * UI to pick between colors in colorArr. can display full transparent (checkerboard).
 * Can't deal with 0.5 alpha.
 * Rectangular buttons.
 */
export class ColorOptions {

    private readonly rootEl: HTMLElement;
    private readonly buttonArr: {
        el: HTMLElement;
        setIsSelected: (b: boolean) => void;
    }[];
    private readonly colorArr: (IRGBA | null)[] = [];
    private selectedIndex: number = 0;
    private colorInput: HTMLInputElement;

    private readonly updateCheckerboard: () => void;
    private readonly onColorInputChange: () => void;

    // ---- public ----
    constructor (
        p: {
            colorArr: (IRGBA | null)[]; // duplicates will be removed
            onChange: (rgbaObj: IRGBA | null) => void;
            label?: string;
            initialIndex?: number; // index before duplicates were removed
            title?: string;
        }
    ) {
        this.rootEl = BB.el({
            content: p.label ? p.label : '',
            title: p.title ?? undefined,
            css: {
                display: 'flex',
                alignItems: 'center',
                gap: '7px',
            },
        });

        this.buttonArr = [];
        const buttonSize = 22;
        const checkerUrl = BB.createCheckerDataUrl(5, undefined, theme.isDark());

        this.onColorInputChange = () => {
            const i = this.selectedIndex;
            const color = this.colorArr[i];
            if (!color || color.a < 1) {
                // ignore
                return;
            }

            const newRawColor = this.colorInput.value;
            this.buttonArr[this.selectedIndex].el.style.backgroundColor = newRawColor;
            this.colorArr[i] = {
                ...ColorConverter.hexToRGB(newRawColor)!,
                a: 1,
            };
            p.onChange(this.colorArr[i]);
        };
        this.colorInput = BB.el({
            tagName: 'input',
            custom: {
                type: 'color',
                tabIndex: '-1',
            },
        });
        this.colorInput.onchange = this.onColorInputChange;
        this.colorInput.oninput = this.onColorInputChange;

        // build colorArr while removing duplicates
        for (let i = 0; i < p.colorArr.length; i++) {
            const item = p.colorArr[i];
            let found = false;
            for (let e = 0; e < this.colorArr.length; e++) {
                const sItem = this.colorArr[e];
                if (sItem === item) {
                    found = true;
                    break;
                }
                if (sItem === null || item === null) {
                    continue;
                }
                if (sItem.r === item.r && sItem.g === item.g && sItem.b === item.b && sItem.a === item.a) {
                    found = true;
                    break;
                }
            }
            if (found) {
                continue;
            }
            this.colorArr.push(item);
            if ('initialIndex' in p && p.initialIndex === i) {
                this.selectedIndex = this.colorArr.length - 1;
            }
        }

        for (let i = 0; i < this.colorArr.length; i++) {
            ((i) => {
                const color = this.colorArr[i];

                const colorButton = BB.el({
                    parent: this.rootEl,
                    content: color ? '' : 'X',
                    className: 'kl-color-option',
                    css: {
                        width: buttonSize + 'px',
                        height: buttonSize + 'px',
                        backgroundColor: color ? BB.ColorConverter.toRgbaStr(color) : 'transparent',
                        lineHeight: (buttonSize + 1) + 'px',
                    },
                    onClick: (e) => {
                        if (this.selectedIndex === i) {
                            if (color && color.a === 1) {
                                this.colorInput.showPicker ? this.colorInput.showPicker() : this.colorInput.click();
                            }
                            return;
                        }

                        e.preventDefault();
                        this.selectedIndex = i;
                        update();
                        p.onChange(this.colorArr[i]); // color may change
                    },
                });
                if (color && color.a === 0) {
                    colorButton.style.backgroundImage = 'url(' + checkerUrl + ')';
                }

                const setIsSelected = (b: boolean): void => {
                    colorButton.classList.toggle('kl-color-option--active', b);
                };

                this.buttonArr.push({
                    el: colorButton,
                    setIsSelected,
                });
            })(i);
        }

        this.rootEl.append(c(',w-0,h-0,overflow-hidden', [this.colorInput]));

        const update = () => {
            for (let i = 0; i < this.buttonArr.length; i++) {
                this.buttonArr[i].setIsSelected(i === this.selectedIndex);
            }
        };
        update();

        this.updateCheckerboard = (): void => {
            const checkerUrl = BB.createCheckerDataUrl(5, undefined, theme.isDark());
            this.buttonArr.forEach((button, i) => {
                const color = this.colorArr[i];
                if (color && color.a === 0) {
                    button.el.style.backgroundImage = 'url(' + checkerUrl + ')';
                }
            });
        };
        theme.addIsDarkListener(this.updateCheckerboard);
    }

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

    getValue (): IRGBA | null {
        return this.colorArr[this.selectedIndex];
    }

    destroy (): void {
        this.buttonArr.forEach(item => {
            BB.destroyEl(item.el);
        });
        this.buttonArr.splice(0, this.buttonArr.length);
        theme.removeIsDarkListener(this.updateCheckerboard);
        this.colorInput.onchange = null;
        this.colorInput.oninput = null;
    }

}


code:
import {BB} from '../../../bb/bb';
import {LANG} from '../../../language/language';
import iconImg from '/src/app/img/ui/brush-pressure.svg';
import {BoxToggle} from './box-toggle';

/**
 * small toggle button with a pen icon - representing toggling pressure sensitivity
 * @param isChecked initial value
 * @param changeCallback called on change
 */
export const createPenPressureToggle = function (
    isChecked: boolean,
    changeCallback: (b: boolean) => void,
): HTMLElement {

    const toggleEl = new BoxToggle({
        label: BB.el({
            className: 'dark-invert',
            css: {
                width: '17px',
                height: '17px',
                backgroundImage: 'url("' + iconImg + '")',
                backgroundSize: 'contain',
                backgroundRepeat: 'no-repeat',
                margin: '1px',
                borderRadius: '3px',
            },
        }),
        title: LANG('brush-toggle-pressure'),
        init: isChecked,
        onChange: (b) => {
            changeCallback(b);
        },
    });

    return toggleEl.getElement();
};


code:
import {BB} from '../../../bb/bb';
import {KeyListener} from '../../../bb/input/key-listener';
import {PointerListener} from '../../../bb/input/pointer-listener';
import {IRect, IVector2D} from '../../../bb/bb-types';
import {theme} from '../../../theme/theme';

/**
 * element that lets you crop an image and copy it via right click
 */
export class CropCopy {

    private readonly rootEl: HTMLElement;
    private readonly eventTarget: HTMLElement;
    private crop: IRect = {x: 0, y: 0, width: 0, height: 0};
    private readonly croppedCanvas: HTMLCanvasElement;
    private readonly keyListener: KeyListener;
    private readonly pointerListener: PointerListener;
    private readonly canvas: HTMLImageElement | HTMLCanvasElement;
    private readonly onChange: undefined | ((width: number, height: number) => void);
    private readonly selectionRect: HTMLElement;
    private readonly thumbX: number;
    private readonly thumbY: number;
    private readonly scaleW: number;
    private readonly scaleH: number;
    private readonly croppedImage: undefined | HTMLImageElement;

    private resetCrop (): void {
        this.crop = {
            x: 0,
            y: 0,
            width: this.canvas.width,
            height: this.canvas.height,
        };
    }
    private updateCroppedCanvas (): void {
        this.croppedCanvas.width = Math.round(this.crop.width);
        this.croppedCanvas.height = Math.round(this.crop.height);
        const ctx = BB.ctx(this.croppedCanvas);
        ctx.drawImage(this.canvas, Math.round(-this.crop.x), Math.round(-this.crop.y));
        if (this.croppedImage) {
            this.croppedImage.src = this.croppedCanvas.toDataURL('image/png');
        }

        this.onChange && this.onChange(this.croppedCanvas.width, this.croppedCanvas.height);
    }
    private updateSelectionRect (): void {
        BB.css(this.selectionRect, {
            left: (this.thumbX + this.crop.x * this.scaleW) + 'px',
            top: (this.thumbY + this.crop.y * this.scaleH) + 'px',
            width: (this.crop.width * this.scaleW) + 'px',
            height: (this.crop.height * this.scaleH) + 'px',
        });
        this.onChange && this.onChange(Math.round(this.crop.width), Math.round(this.crop.height));
    }

    // ---- public ----

    constructor (
        param: {
            width: number;
            height: number;
            canvas: HTMLImageElement | HTMLCanvasElement;
            isNotCopy: boolean;
            onChange?: (width: number, height: number) => void;
        }
    ) {
        this.rootEl = BB.el({
            className: 'kl-edit-crop-preview',
            css: {
                position: 'relative',
                height: param.height + 'px',
                width: param.width + 'px',
                overflow: 'hidden',
            },
        });
        if (param.onChange) {
            this.onChange = param.onChange;
        }
        this.canvas = param.canvas;

        this.resetCrop();

        const isInsideSelectionRect = (p: IVector2D): boolean => {
            const rect = {
                x: Math.round(this.thumbX + this.crop.x * this.scaleW),
                y: Math.round(this.thumbY + this.crop.y * this.scaleH),
                width: Math.round(this.crop.width * this.scaleW),
                height: Math.round(this.crop.height * this.scaleH),
            };
            return BB.isInsideRect(p, rect);
        };

        this.croppedCanvas = BB.canvas();
        this.eventTarget = this.croppedCanvas;
        if (!param.isNotCopy) {
            this.croppedImage = new Image();
            this.eventTarget = this.croppedImage;
        }
        BB.css(this.eventTarget, {
            height: param.height + 'px',
            width: param.width + 'px',
        });
        this.rootEl.append(this.eventTarget);
        this.updateCroppedCanvas();

        const padding = 20;
        const previewWrapper = BB.el({
            css: {
                width: param.width + 'px',
                height: param.height + 'px',
                position: 'absolute',
                left: '0',
                top: '0',
                pointerEvents: 'none',
            },
        });
        this.rootEl.append(previewWrapper);
        BB.createCheckerDataUrl(4, (v) => {
            previewWrapper.style.backgroundImage = 'url('+v+')';
        }, theme.isDark());

        const thumbSize = BB.fitInto(this.canvas.width, this.canvas.height, param.width - padding * 2, param.height - padding * 2, 1);
        const thumbCanvas = BB.canvas(Math.round(thumbSize.width), Math.round(thumbSize.height));
        thumbCanvas.style.imageRendering = 'pixelated';
        this.scaleW = thumbCanvas.width / this.canvas.width;
        this.scaleH = thumbCanvas.height / this.canvas.height;
        const thumbCtx = BB.ctx(thumbCanvas);
        thumbCtx.imageSmoothingEnabled = false;
        thumbCtx.drawImage(this.canvas, 0, 0, thumbCanvas.width, thumbCanvas.height);
        previewWrapper.append(thumbCanvas);
        this.thumbX = parseInt('' + ((param.width - thumbCanvas.width) / 2));
        this.thumbY = parseInt('' + ((param.height - thumbCanvas.height) / 2));
        BB.css(thumbCanvas, {
            position: 'absolute',
            left: this.thumbX + 'px',
            top: this.thumbY + 'px',
        });

        this.selectionRect = BB.el({
            parent: previewWrapper,
            className: 'kl-edit-crop-preview__sel',
        });
        this.updateSelectionRect();

        const toOriginalSpace = (p: IVector2D): IVector2D => {
            return {
                x: BB.clamp((p.x - this.thumbX) / this.scaleW, 0, this.canvas.width),
                y: BB.clamp((p.y - this.thumbY) / this.scaleH, 0, this.canvas.height),
            };
        };
        //gen crop from thumb-space points
        const genCrop = (p1: IVector2D, p2: IVector2D): IRect => {
            const topLeftP = {
                x: Math.min(p1.x, p2.x),
                y: Math.min(p1.y, p2.y),
            };
            const bottomRightP = {
                x: Math.max(p1.x, p2.x),
                y: Math.max(p1.y, p2.y),
            };
            const origTopLeftP = toOriginalSpace(topLeftP);
            const origBottomRightP = toOriginalSpace(bottomRightP);
            origTopLeftP.x = Math.floor(origTopLeftP.x);
            origTopLeftP.y = Math.floor(origTopLeftP.y);
            origBottomRightP.x = Math.ceil(origBottomRightP.x);
            origBottomRightP.y = Math.ceil(origBottomRightP.y);
            return {
                x: origTopLeftP.x,
                y: origTopLeftP.y,
                width: origBottomRightP.x - origTopLeftP.x,
                height: origBottomRightP.y - origTopLeftP.y,
            };
        };

        let startP: IVector2D | null;
        let startCrop: IRect | null = null;
        let isDragging = false;
        let didMove = false;
        let updateCropTimeout: ReturnType<typeof setTimeout>;
        this.pointerListener = new BB.PointerListener({
            target: this.eventTarget,
            fixScribble: true,
            onPointer: (event) => {
                if (event.type === 'pointerdown' && event.button === 'left') {
                    event.eventPreventDefault();
                    isDragging = true;
                    startP = {
                        x: event.relX,
                        y: event.relY,
                    };
                    if (!this.isReset() && isInsideSelectionRect(startP)) {
                        startCrop = {
                            x: this.crop.x,
                            y: this.crop.y,
                            width: this.crop.width,
                            height: this.crop.height,
                        };
                    } else {
                        this.crop = genCrop(startP, startP);
                    }
                } else if (event.type === 'pointermove' && event.button === 'left') {
                    event.eventPreventDefault();
                    didMove = true;
                    if (startCrop) {
                        this.crop.x = startCrop.x + Math.round((event.relX - startP!.x) / this.scaleW);
                        this.crop.y = startCrop.y + Math.round((event.relY - startP!.y) / this.scaleH);
                        this.crop.x = BB.clamp(this.crop.x, 0, this.canvas.width - this.crop.width);
                        this.crop.y = BB.clamp(this.crop.y, 0, this.canvas.height - this.crop.height);
                    } else {
                        this.crop = genCrop(startP!, {x: event.relX, y: event.relY});
                    }
                    this.updateSelectionRect();
                } else if (event.type === 'pointerup' && startP) {
                    event.eventPreventDefault();
                    isDragging = false;
                    startCrop = null;
                    startP = null;
                    if (this.crop.width === 0 || this.crop.height === 0 || !didMove) {
                        this.resetCrop();
                        this.updateSelectionRect();
                    }
                    didMove = false;
                    updateCropTimeout = setTimeout(() => this.updateCroppedCanvas(), 1);
                }
            },
        });

        this.keyListener = new BB.KeyListener({
            onDown: (keyStr, e, comboStr) => {
                if (isDragging) {
                    return;
                }
                let doUpdate = false;

                const stepSize = Math.max(1, 1 / this.scaleW);
                const shiftIsPressed = this.keyListener.isPressed('shift');

                if (keyStr === 'left') {
                    if (shiftIsPressed) {
                        this.crop.width = BB.clamp(this.crop.width - stepSize, 1, this.canvas.width - this.crop.x);
                    } else {
                        this.crop.x = BB.clamp(this.crop.x - stepSize, 0, this.canvas.width - this.crop.width);
                    }
                    doUpdate = true;
                }
                if (keyStr === 'right') {
                    if (shiftIsPressed) {
                        this.crop.width = BB.clamp(this.crop.width + stepSize, 1, this.canvas.width - this.crop.x);
                    } else {
                        this.crop.x = BB.clamp(this.crop.x + stepSize, 0, this.canvas.width - this.crop.width);
                    }
                    doUpdate = true;
                }
                if (keyStr === 'up') {
                    if (shiftIsPressed) {
                        this.crop.height = BB.clamp(this.crop.height - stepSize, 1, this.canvas.height - this.crop.y);
                    } else {
                        this.crop.y = BB.clamp(this.crop.y - stepSize, 0, this.canvas.height - this.crop.height);
                    }
                    doUpdate = true;
                }
                if (keyStr === 'down') {
                    if (shiftIsPressed) {
                        this.crop.height = BB.clamp(this.crop.height + stepSize, 1, this.canvas.height - this.crop.y);
                    } else {
                        this.crop.y = BB.clamp(this.crop.y + stepSize, 0, this.canvas.height - this.crop.height);
                    }
                    doUpdate = true;
                }

                if (doUpdate) {
                    e.preventDefault();
                    this.updateSelectionRect();
                    clearTimeout(updateCropTimeout);
                    updateCropTimeout = setTimeout(() => this.updateCroppedCanvas(), 100);
                }
            },
        });
    }

    // ---- interface ----

    getEl (): HTMLElement {
        return this.rootEl;
    }
    reset (): void {
        this.resetCrop();
        this.updateCroppedCanvas();
        this.updateSelectionRect();
    }
    destroy (): void {
        this.eventTarget.style.removeProperty('width');
        this.eventTarget.style.removeProperty('height');
        this.keyListener.destroy();
        this.pointerListener.destroy();
    }
    isReset (): boolean {
        return this.crop.x === 0 && this.crop.y === 0 && this.crop.width === this.canvas.width && this.crop.height === this.canvas.height;
    }
    getRect (): IRect {
        return BB.copyObj(this.crop);
    }
    getCroppedImage (): HTMLCanvasElement {
        return this.croppedCanvas;
    }
}