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

type TUpdateElement = {
    el: HTMLElement;
    update: () => void;
};

/**
 * Interactive preview for crop filter. Corners and edges can be dragged.
 * the div that you append this to must be relative
 */
export class Cropper {

    private readonly rootEl: HTMLElement;
    private x: number;
    private y: number;
    private width: number;
    private height: number;
    private scale: number;
    private readonly grips: IVector2D[]; // aka corner coordinates
    private readonly edges: TUpdateElement[];
    private readonly cornerElArr: TUpdateElement[];
    private readonly darken: TUpdateElement[];
    private readonly outline: TUpdateElement;
    private readonly thirdsHorizontal: TUpdateElement;
    private readonly thirdsVertical: TUpdateElement;
    private readonly keyListener: KeyListener;
    private readonly pointerRemainder: IVector2D;

    private readonly outlinePointerListener: PointerListener;

    private readonly corner0PointerListener: PointerListener;
    private readonly corner1PointerListener: PointerListener;
    private readonly corner2PointerListener: PointerListener;
    private readonly corner3PointerListener: PointerListener;

    private readonly edge0PointerListener: PointerListener;
    private readonly edge1PointerListener: PointerListener;
    private readonly edge2PointerListener: PointerListener;
    private readonly edge3PointerListener: PointerListener;

    private readonly callback: (val: IRect) => void;

    private update (): void {
        this.edges[0].update();
        this.edges[1].update();
        this.edges[2].update();
        this.edges[3].update();
        this.cornerElArr[0].update();
        this.cornerElArr[1].update();
        this.cornerElArr[2].update();
        this.cornerElArr[3].update();
        this.darken[0].update();
        this.darken[1].update();
        this.darken[2].update();
        this.darken[3].update();
        this.outline.update();
        this.thirdsHorizontal.update();
        this.thirdsVertical.update();
    }

    private commit (): void {
        this.pointerRemainder.x = 0;
        this.pointerRemainder.y = 0;
        this.callback(this.getTransform());
    }

    // ---- public ----
    constructor (
        params: {
            x: number; // int, pos in relation to image
            y: number; // int
            width: number; // int
            height: number; // int
            maxW: number;
            maxH: number;
            scale: number; // float, zoom
            callback: (val: IRect) => void;
        }
    ) {

        this.x = params.x;
        this.y = params.y;
        this.width = params.width;
        this.height = params.height;
        this.scale = params.scale;
        this.callback = params.callback;

        const maxW = params.maxW;
        const maxH = params.maxW;
        this.rootEl = BB.el();
        const gripCursors = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];

        this.keyListener = new BB.KeyListener({});

        BB.css(this.rootEl, {
            position: 'absolute',
            left: (this.x * this.scale) + 'px',
            top: (this.y * this.scale) + 'px',
        });

        this.outline = {
            el: BB.el({
                css: {
                    position: 'absolute',
                    border: '1px dashed #fff',
                    cursor: 'move',
                },
            }),
            update: () => {
                BB.css(this.outline.el, {
                    left: (this.grips[0].x * this.scale - 1) + 'px',
                    top: (this.grips[0].y * this.scale - 1) + 'px',
                    width: ((this.grips[2].x - this.grips[0].x) * this.scale) + 'px',
                    height: ((this.grips[2].y - this.grips[0].y) * this.scale) + 'px',
                });
            },
        };

        this.pointerRemainder = { // needs to be reset after dragging complete
            x: 0,
            y: 0,
        };
        this.outlinePointerListener = new BB.PointerListener({
            target: this.outline.el,
            onPointer: (event) => {
                event.eventPreventDefault();
                if (event.type === 'pointermove' && event.button === 'left') {

                    const {dX, dY} = BB.intDxy(this.pointerRemainder, event.dX / this.scale, event.dY / this.scale);

                    this.grips[0].x += dX;
                    this.grips[0].y += dY;
                    this.grips[1].x += dX;
                    this.grips[1].y += dY;
                    this.grips[2].x += dX;
                    this.grips[2].y += dY;
                    this.grips[3].x += dX;
                    this.grips[3].y += dY;

                    this.update();
                }
                if (event.type === 'pointerup') {
                    this.commit();
                }
            },
        });

        this.thirdsHorizontal = {
            el: BB.el({
                css: {
                    position: 'absolute',
                    borderTop: '1px solid #0ff',
                    borderBottom: '1px solid #0ff',
                },
            }),
            update: () => {
                BB.css(this.thirdsHorizontal.el, {
                    left: (this.grips[0].x * this.scale) + 'px',
                    top: ((this.grips[0].y + (this.grips[2].y - this.grips[0].y) / 3) * this.scale) + 'px',
                    width: ((this.grips[2].x - this.grips[0].x) * this.scale) + 'px',
                    height: ((this.grips[2].y - this.grips[0].y) / 3 * this.scale) + 'px',
                });
            },
        };

        this.thirdsVertical = {
            el: BB.el({
                css: {
                    position: 'absolute',
                    borderLeft: '1px solid #0ff',
                    borderRight: '1px solid #0ff',
                },
            }),
            update: () => {
                BB.css(this.thirdsVertical.el, {
                    left: ((this.grips[0].x + (this.grips[2].x - this.grips[0].x) / 3) * this.scale) + 'px',
                    top: (this.grips[0].y * this.scale) + 'px',
                    width: ((this.grips[2].x - this.grips[0].x) / 3 * this.scale) + 'px',
                    height: ((this.grips[2].y - this.grips[0].y) * this.scale) + 'px',
                });
            },
        };

        const gripSize = 40;
        const gripOverlay = 10;

        this.grips = [
            {x: 0, y: 0}, // top left
            {x: this.width, y: 0}, // top right
            {x: this.width, y: this.height}, // bottom right
            {x: 0, y: this.height}, //bottom left
        ];

        const transformTop = (dY: number) => {
            this.grips[0].y += dY;
            this.grips[0].y = BB.clamp(this.grips[0].y, this.grips[3].y - maxH, this.grips[3].y - 1);
            this.grips[1].y = this.grips[0].y;
        };
        const transformRight = (dX: number) => {
            this.grips[1].x += dX;
            this.grips[1].x = BB.clamp(this.grips[1].x, this.grips[0].x + 1, this.grips[0].x + maxW);
            this.grips[2].x = this.grips[1].x;
        };
        const transformBottom = (dY: number) => {
            this.grips[2].y += dY;
            this.grips[2].y = BB.clamp(this.grips[2].y, this.grips[1].y + 1, this.grips[1].y + maxH);
            this.grips[3].y = this.grips[2].y;
        };
        const transformLeft = (dX: number) => {
            this.grips[0].x += dX;
            this.grips[0].x = BB.clamp(this.grips[0].x, this.grips[1].x - maxW, this.grips[1].x - 1);
            this.grips[3].x = this.grips[0].x;
        };

        this.edges = [];
        for (let i = 0; i < 4; i++) {
            ((i) => {

                const el = BB.el({
                    css: {
                        width: gripSize + 'px',
                        height: gripSize + 'px',
                        //background: '#0f0',
                        position: 'absolute',
                    },
                });

                const update = () => {
                    if (i === 0) { //top
                        BB.css(el, {
                            left: (this.grips[0].x * this.scale + gripOverlay) + 'px',
                            top: (this.grips[0].y * this.scale - gripSize * 2 + gripOverlay) + 'px',
                            width: ((this.grips[1].x - this.grips[0].x) * this.scale - gripOverlay * 2) + 'px',
                            height: (gripSize * 2) + 'px',
                        });
                    } else if (i === 1) { //right
                        BB.css(el, {
                            left: (this.grips[1].x * this.scale - gripOverlay) + 'px',
                            top: (this.grips[1].y * this.scale + gripOverlay) + 'px',
                            width: (gripSize * 2) + 'px',
                            height: ((this.grips[2].y - this.grips[1].y) * this.scale - gripOverlay * 2) + 'px',
                        });
                    } else if (i === 2) { //bottom
                        BB.css(el, {
                            left: (this.grips[3].x * this.scale + gripOverlay) + 'px',
                            top: (this.grips[3].y * this.scale - gripOverlay) + 'px',
                            width: ((this.grips[2].x - this.grips[3].x) * this.scale - gripOverlay * 2) + 'px',
                            height: (gripSize * 2) + 'px',
                        });
                    } else if (i === 3) { //left
                        BB.css(el, {
                            left: (this.grips[0].x * this.scale - gripSize * 2 + gripOverlay) + 'px',
                            top: (this.grips[0].y * this.scale + gripOverlay) + 'px',
                            width: (gripSize * 2) + 'px',
                            height: ((this.grips[3].y - this.grips[0].y) * this.scale - gripOverlay * 2) + 'px',
                        });
                    }
                    const angleOffset = i * 2 + 1;
                    el.style.cursor = gripCursors[angleOffset] + '-resize';

                };

                this.edges[i] = {
                    el,
                    update,
                };
            })(i);
        }

        this.darken = [];
        for (let i = 0; i < 4; i++) {
            ((i) => {
                const g = BB.el({
                    css: {
                        position: 'absolute',
                        background: '#000',
                        opacity: '0.5',
                    },
                });
                const update = () => {
                    if (i === 0) {
                        BB.css(g, {
                            left: (this.grips[0].x * this.scale) + 'px',
                            top: (this.grips[0].y * this.scale - 8000) + 'px',
                            width: ((this.grips[1].x - this.grips[0].x) * this.scale) + 'px',
                            height: '8000px',
                        });
                    } else if (i === 1) {
                        BB.css(g, {
                            left: (this.grips[1].x * this.scale) + 'px',
                            top: (this.grips[1].y * this.scale - 8000) + 'px',
                            width: '8000px',
                            height: 16000 + 'px',
                        });
                    } else if (i === 2) {
                        BB.css(g, {
                            left: (this.grips[3].x * this.scale) + 'px',
                            top: (this.grips[3].y * this.scale) + 'px',
                            width: ((this.grips[2].x - this.grips[3].x) * this.scale) + 'px',
                            height: '8000px',
                        });
                    } else if (i === 3) {
                        BB.css(g, {
                            left: (this.grips[0].x * this.scale - 8000) + 'px',
                            top: (this.grips[0].y * this.scale - 8000) + 'px',
                            width: '8000px',
                            height: 16000 + 'px',
                        });
                    }

                };

                this.darken[i] = {
                    el: g,
                    update,
                };
            })(i);
        }

        this.edge0PointerListener = new BB.PointerListener({
            target: this.edges[0].el,
            fixScribble: true,
            onPointer: (event) => {
                event.eventPreventDefault();
                if (event.type === 'pointermove' && event.button === 'left') {
                    const {dY} = BB.intDxy(this.pointerRemainder, event.dX / this.scale, event.dY / this.scale);
                    transformTop(dY);
                    if (this.keyListener.isPressed('shift')) {
                        transformBottom(-dY);
                    }
                    this.update();
                }
                if (event.type === 'pointerup') {
                    this.commit();
                }
            },
        });
        this.edge1PointerListener = new BB.PointerListener({
            target: this.edges[1].el,
            fixScribble: true,
            onPointer: (event) => {
                event.eventPreventDefault();
                if (event.type === 'pointermove' && event.button === 'left') {
                    const {dX, dY} = BB.intDxy(this.pointerRemainder, event.dX / this.scale, event.dY / this.scale);
                    transformRight(dX);
                    if (this.keyListener.isPressed('shift')) {
                        transformLeft(-dX);
                    }
                    this.update();
                }
                if (event.type === 'pointerup') {
                    this.commit();
                }
            },
        });
        this.edge2PointerListener = new BB.PointerListener({
            target: this.edges[2].el,
            fixScribble: true,
            onPointer: (event) => {
                event.eventPreventDefault();
                if (event.type === 'pointermove' && event.button === 'left') {
                    const {dX, dY} = BB.intDxy(this.pointerRemainder, event.dX / this.scale, event.dY / this.scale);
                    transformBottom(dY);
                    if (this.keyListener.isPressed('shift')) {
                        transformTop(-dY);
                    }
                    this.update();
                }
                if (event.type === 'pointerup') {
                    this.commit();
                }
            },
        });
        this.edge3PointerListener = new BB.PointerListener({
            target: this.edges[3].el,
            fixScribble: true,
            onPointer: (event) => {
                event.eventPreventDefault();
                if (event.type === 'pointermove' && event.button === 'left') {
                    const {dX, dY} = BB.intDxy(this.pointerRemainder, event.dX / this.scale, event.dY / this.scale);
                    transformLeft(dX);
                    if (this.keyListener.isPressed('shift')) {
                        transformRight(-dX);
                    }
                    this.update();
                }
                if (event.type === 'pointerup') {
                    this.commit();
                }
            },
        });

        this.cornerElArr = [];
        (() => {
            for (let i = 0; i < 4; i++) {
                ((i) => {
                    const g = BB.el({
                        css: {
                            //background: '#f00',
                            width: (gripSize * 2) + 'px',
                            height: (gripSize * 2) + 'px',
                            position: 'absolute',
                            cursor: ['nwse-resize', 'nesw-resize'][i % 2],
                        },
                    });

                    const update = () => {
                        if (i === 0) { //top left
                            BB.css(g, {
                                left: (this.grips[0].x * this.scale - gripSize * 2 + gripOverlay) + 'px',
                                top: (this.grips[0].y * this.scale - gripSize * 2 + gripOverlay) + 'px',
                            });
                        } else if (i === 1) { //top right
                            BB.css(g, {
                                left: (this.grips[1].x * this.scale - gripOverlay) + 'px',
                                top: (this.grips[1].y * this.scale - gripSize * 2 + gripOverlay) + 'px',
                            });
                        } else if (i === 2) { //bottom right
                            BB.css(g, {
                                left: (this.grips[1].x * this.scale - gripOverlay) + 'px',
                                top: (this.grips[2].y * this.scale - gripOverlay) + 'px',
                            });
                        } else if (i === 3) { //bottom left
                            BB.css(g, {
                                left: (this.grips[0].x * this.scale - gripSize * 2 + gripOverlay) + 'px',
                                top: (this.grips[2].y * this.scale - gripOverlay) + 'px',
                            });
                        }
                    };

                    this.cornerElArr[i] = {
                        el: g,
                        update,
                    };
                })(i);
            }
        })();
        //top left
        this.corner0PointerListener = new BB.PointerListener({
            target: this.cornerElArr[0].el,
            fixScribble: true,
            onPointer: (event) => {
                event.eventPreventDefault();
                if (event.type === 'pointermove' && event.button === 'left') {
                    const {dX, dY} = BB.intDxy(this.pointerRemainder, event.dX / this.scale, event.dY / this.scale);
                    transformLeft(dX);
                    transformTop(dY);
                    if (this.keyListener.isPressed('shift')) {
                        transformRight(-dX);
                        transformBottom(-dY);
                    }
                    this.update();
                }
                if (event.type === 'pointerup') {
                    this.commit();
                }
            },
        });
        //top right
        this.corner1PointerListener = new BB.PointerListener({
            target: this.cornerElArr[1].el,
            fixScribble: true,
            onPointer: (event) => {
                event.eventPreventDefault();
                if (event.type === 'pointermove' && event.button === 'left') {
                    const {dX, dY} = BB.intDxy(this.pointerRemainder, event.dX / this.scale, event.dY / this.scale);
                    transformRight(dX);
                    transformTop(dY);
                    if (this.keyListener.isPressed('shift')) {
                        transformLeft(-dX);
                        transformBottom(-dY);
                    }
                    this.update();
                }
                if (event.type === 'pointerup') {
                    this.commit();
                }
            },
        });
        //bottom right
        this.corner2PointerListener = new BB.PointerListener({
            target: this.cornerElArr[2].el,
            fixScribble: true,
            onPointer: (event) => {
                event.eventPreventDefault();
                if (event.type === 'pointermove' && event.button === 'left') {
                    const {dX, dY} = BB.intDxy(this.pointerRemainder, event.dX / this.scale, event.dY / this.scale);
                    transformRight(dX);
                    transformBottom(dY);
                    if (this.keyListener.isPressed('shift')) {
                        transformLeft(-dX);
                        transformTop(-dY);
                    }
                    this.update();
                }
                if (event.type === 'pointerup') {
                    this.commit();
                }
            },
        });
        //bottom left
        this.corner3PointerListener = new BB.PointerListener({
            target: this.cornerElArr[3].el,
            fixScribble: true,
            onPointer: (event) => {
                event.eventPreventDefault();
                if (event.type === 'pointermove' && event.button === 'left') {
                    const {dX, dY} = BB.intDxy(this.pointerRemainder, event.dX / this.scale, event.dY / this.scale);
                    transformLeft(dX);
                    transformBottom(dY);
                    if (this.keyListener.isPressed('shift')) {
                        transformRight(-dX);
                        transformTop(-dY);
                    }
                    this.update();
                }
                if (event.type === 'pointerup') {
                    this.commit();
                }
            },
        });

        this.rootEl.append(
            this.darken[1].el,
            this.darken[0].el,
            this.darken[2].el,
            this.darken[3].el,
            this.thirdsHorizontal.el,
            this.thirdsVertical.el,
            this.outline.el,

            this.edges[1].el,
            this.edges[0].el,
            this.edges[2].el,
            this.edges[3].el,

            this.cornerElArr[0].el,
            this.cornerElArr[1].el,
            this.cornerElArr[2].el,
            this.cornerElArr[3].el,
        );

        this.update();

    }

    // ---- interface ----
    getTransform (): IRect {
        this.grips[1].x -= this.grips[0].x;
        this.grips[1].y -= this.grips[0].y;
        this.grips[2].x -= this.grips[0].x;
        this.grips[2].y -= this.grips[0].y;
        this.grips[3].x -= this.grips[0].x;
        this.grips[3].y -= this.grips[0].y;
        this.x += this.grips[0].x;
        this.y += this.grips[0].y;
        this.grips[0].x = 0;
        this.grips[0].y = 0;
        return {
            x: this.x,
            y: this.y,
            width: this.grips[1].x,
            height: this.grips[2].y,
        };
    }

    setTransform (p: IRect): void {
        this.x = p.x;
        this.y = p.y;
        this.width = p.width;
        this.height = p.height;

        BB.css(this.rootEl, {
            left: (this.x * this.scale) + 'px',
            top: (this.y * this.scale) + 'px',
        });

        this.grips[0].x = 0;
        this.grips[0].y = 0;
        this.grips[1].x = this.width;
        this.grips[1].y = 0;
        this.grips[2].x = this.width;
        this.grips[2].y = this.height;
        this.grips[3].x = 0;
        this.grips[3].y = this.height;

        this.update();
        this.commit();
    }

    setScale (s: number): void {
        this.scale = s;
        BB.css(this.rootEl, {
            left: (this.x * this.scale) + 'px',
            top: (this.y * this.scale) + 'px',
        });
        this.update();
    }

    showThirds (b: boolean): void {

        this.thirdsHorizontal.el.style.display = b ? 'block' : 'none';
        this.thirdsVertical.el.style.display = b ? 'block' : 'none';

    }

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

    destroy (): void {
        this.keyListener.destroy();
        this.outlinePointerListener.destroy();
        this.corner0PointerListener.destroy();
        this.corner1PointerListener.destroy();
        this.corner2PointerListener.destroy();
        this.corner3PointerListener.destroy();
        this.edge0PointerListener.destroy();
        this.edge1PointerListener.destroy();
        this.edge2PointerListener.destroy();
        this.edge3PointerListener.destroy();
    }
}


code:
import {BB} from '../../../bb/bb';
import {KlCanvasPreview} from '../../canvas-ui/canvas-preview';
import {FreeTransform} from './free-transform';
import {IKlBasicLayer} from '../../kl-types';
import {IRect} from '../../../bb/bb-types';

/**
 * a basic canvas where you can transform one layer(move around, rotate, scale)
 *
 * DOM Structure:
 * div
 *      innerWrapper
 *          klCanvasPreview
 *          transform.div
 *
 */
export class FreeTransformCanvas {

    private readonly rootEl: HTMLElement;
    private readonly freeTransform: FreeTransform;
    private readonly layers: IKlBasicLayer[];
    private readonly transformIndex: number;
    private readonly imageWidth: number;
    private readonly imageHeight: number;
    private readonly initTransform: IRect;
    private readonly scale: number;
    private readonly previewLayerArr: IKlBasicLayer[];
    private readonly klCanvasPreview: KlCanvasPreview;

    private updatePreview (): void {
        if (!this.freeTransform) {
            return;
        }

        const transform = this.freeTransform.getTransform();
        if (this.scale < 1) {
            transform.x *= this.scale;
            transform.y *= this.scale;
            transform.width *= this.scale;
            transform.height *= this.scale;
        }

        const destCanvas = this.previewLayerArr[this.transformIndex].image;
        const ctx = BB.ctx((destCanvas as HTMLCanvasElement));
        ctx.save();
        ctx.clearRect(0, 0, destCanvas.width, destCanvas.height);
        BB.drawTransformedImageWithBounds(
            ctx,
            this.layers[this.transformIndex].image,
            transform,
            undefined,
            BB.testShouldPixelate(
                transform,
                transform.width / this.initTransform.width,
                transform.height / this.initTransform.height
            ),
        );
        ctx.restore();
        this.klCanvasPreview.render();
    }

    // ---- public ----
    constructor (
        p: {
            elementWidth: number;
            elementHeight: number;
            imageWidth: number;
            imageHeight: number;
            layers: IKlBasicLayer[];
            transformIndex: number;
        }
    ) {
        this.imageWidth = p.imageWidth;
        this.imageHeight = p.imageHeight;
        const previewFit = BB.fitInto(
            p.imageWidth,
            this.imageHeight,
            p.elementWidth - 20,
            p.elementHeight - 60,
            1
        );
        this.scale = previewFit.width / p.imageWidth;

        this.rootEl = BB.el({
            className: 'kl-preview-wrapper',
            css: {
                width: p.elementWidth + 'px',
                height: p.elementHeight + 'px',
            },
        });
        this.rootEl.oncontextmenu = () => {
            return false;
        };
        this.layers = p.layers;
        this.transformIndex = p.transformIndex;

        const innerWrapper = BB.el({
            className: 'kl-preview-wrapper__canvas',
            css: {
                width: previewFit.width + 'px',
                height: previewFit.height + 'px',
            },
        });
        this.rootEl.append(innerWrapper);

        this.previewLayerArr = this.layers.map(item => {
            return {
                image: item.image,
                isVisible: item.isVisible,
                mixModeStr: item.mixModeStr,
                opacity: item.opacity,
            };
        });
        this.previewLayerArr[this.previewLayerArr.length - 1].image = BB.canvas(
            this.scale > 1 ? this.imageWidth : previewFit.width,
            this.scale > 1 ? this.imageHeight : previewFit.height,
        );
        this.klCanvasPreview = new KlCanvasPreview({
            width: previewFit.width,
            height: previewFit.height,
            layers: this.previewLayerArr,
        });
        innerWrapper.append(this.klCanvasPreview.getElement());

        {
            let transformSize = {
                width: this.layers[this.transformIndex].image.width * this.scale,
                height: this.layers[this.transformIndex].image.height * this.scale,
            };
            if (transformSize.width > previewFit.width || transformSize.height > previewFit.height) {
                transformSize = BB.fitInto(
                    this.layers[this.transformIndex].image.width,
                    this.layers[this.transformIndex].image.height,
                    previewFit.width,
                    previewFit.height,
                    1
                );
            }
            this.initTransform = {
                x: this.imageWidth / 2,
                y: this.imageHeight / 2,
                width: this.layers[this.transformIndex].image.width,
                height: this.layers[this.transformIndex].image.height,
            };
            this.freeTransform = new FreeTransform({
                x: this.initTransform.x,
                y: this.initTransform.y,
                width: this.initTransform.width,
                height: this.initTransform.height,
                angleDeg: 0,
                isConstrained: true,
                snapX: [0, this.imageWidth],
                snapY: [0, this.imageHeight],
                scale: this.scale,
                callback: () => {
                    this.updatePreview();
                },
            });
        }
        BB.css(this.freeTransform.getElement(), {
            position: 'absolute',
            left: '0',
            top: '0',
        });
        innerWrapper.append(this.freeTransform.getElement());
        setTimeout(() => this.updatePreview(), 0);
    }

    // ---- interface ----
    move (dX: number, dY: number): void {
        this.freeTransform.move(dX, dY);
    }

    reset (): void {
        const w = this.layers[this.transformIndex].image.width;
        const h = this.layers[this.transformIndex].image.height;

        this.freeTransform.setSize(w, h);
        this.freeTransform.setPos({x: w / 2, y: h / 2});
        this.freeTransform.setAngleDeg(0);
        this.updatePreview();
    }

    setTransformFit (): void {

        const fit = BB.fitInto(
            this.layers[this.transformIndex].image.width,
            this.layers[this.transformIndex].image.height,
            this.imageWidth,
            this.imageHeight,
            1
        );

        this.freeTransform.setSize(fit.width, fit.height);
        this.freeTransform.setPos({x: fit.width / 2, y: fit.height / 2});
        this.freeTransform.setAngleDeg(0);
        this.updatePreview();
    }

    setTransformCenter (): void {
        this.freeTransform.setPos({x: this.imageWidth / 2, y: this.imageHeight / 2});
        this.freeTransform.setAngleDeg(0);
        this.updatePreview();
    }

    /**
     * gives you the transformation in the original scale
     */
    getTransformation (): any { // todo
        if (!this.freeTransform) {
            return false;
        }
        return this.freeTransform.getTransform();
    }

    getIsPixelated (): boolean {
        const transform = this.freeTransform.getTransform();
        return BB.testShouldPixelate(
            transform,
            transform.width / this.initTransform.width,
            transform.height / this.initTransform.height
        );
    }

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

    destroy (): void {
        this.freeTransform.destroy();
        this.klCanvasPreview.destroy();
    }
}

repo_name: Doggermelon/klecks

license: mit


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

export interface IFreeTransform {
    x: number; // center of transform region. image space
    y: number;
    width: number; // size of transform region. image space
    height: number;
    angleDeg: number; // angle of transform region. degrees
}

export type TFreeTransformCorner = {
    i: number; // index in corners array
    el: HTMLElement; // draggable corner circle in DOM
    x: number; // unscaled position (transform space)
    y: number;
    virtualPos: IVector2D; // unscaled temporary position (image space)
    updateDOM: () => void; // update styling in DOM
    pointerListener: PointerListener;
};

export type TFreeTransformEdge = { // derive position from corners
    el: HTMLElement;
    updateDOM: () => void;
    pointerListener: PointerListener;
};

/**
 * snap entire transform to pixel grid. changes transform
 *
 * for x y:
 * If a dimension has an even size, it be an integer.
 * If it's uneven, it's sits exactly half way between two pixels.
 *
 * @param transform
 */
export function snapToPixel (transform: IFreeTransform): void {
    if (Math.abs(transform.angleDeg) % 90 !== 0) {
        return;
    }

    transform.width = Math.round(transform.width);
    transform.height = Math.round(transform.height);
    // 0° is original orientation.
    // At 90° and 270° width and height become swapped due to different orientation.
    const whSwapped = Math.abs(transform.angleDeg - 90) % 180 === 0;
    transform.x = ((whSwapped ? transform.height : transform.width) % 2 === 0) ? Math.round(transform.x) : Math.round(transform.x - 0.5) + 0.5;
    transform.y = ((whSwapped ? transform.width : transform.height) % 2 === 0)  ? Math.round(transform.y) : Math.round(transform.y - 0.5) + 0.5;
}

export function copyTransform (transform: IFreeTransform): IFreeTransform {
    return {
        x: transform.x,
        y: transform.y,
        width: transform.width,
        height: transform.height,
        angleDeg: transform.angleDeg,
    };
}

/**
 * image space to transform space
 * - origin of transform space is at center of transform bounds.
 * - same scale as image space. -> one unit is x: 1/width, y: 1/height
 * - up is where transform points up
 * - x goes right
 * - y goes down
 * @param x
 * @param y
 * @param transform
 */
export function toTransformSpace (x: number, y: number, transform: IFreeTransform): IVector2D {
    let px, py;
    px = x - transform.x;
    py = y - transform.y;

    const rot = BB.rotateAround({x: 0, y: 0},
        {x: px, y: py},
        -transform.angleDeg);
    px = rot.x;
    py = rot.y;

    return {
        x: px,
        y: py,
    };
}

/**
 * transform space to image space
 * @param x
 * @param y
 * @param transform
 */
export function toImageSpace (x: number, y: number, transform: IFreeTransform): IVector2D {
    const rot = BB.rotateAround({x: 0, y: 0},
        {x: x, y: y},
        transform.angleDeg);
    return {
        x: rot.x + transform.x,
        y: rot.y + transform.y,
    };
}


code:
import {BB} from '../../../bb/bb';
import rotateImg from '/src/app/img/ui/cursor-rotate.png';
import {KeyListener} from '../../../bb/input/key-listener';
import {IVector2D} from '../../../bb/bb-types';
import {PointerListener} from '../../../bb/input/pointer-listener';
import {
    copyTransform,
    IFreeTransform,
    snapToPixel,
    TFreeTransformCorner,
    TFreeTransformEdge,
    toImageSpace,
    toTransformSpace,
} from './free-transform-utils';

/**
 * Free Transform UI
 * rotate, scale, translate
 *
 * - if rotation is multiple of 90° it will snap to pixels, to be more useful for pixel art
 * - when rotation goes from non-multiple of 90° to a multiple, it will snap position and width height to pixels
 * - transform.x, transform.y can sit between pixels (by 0.5) if width or height is odd number.
 *      - this is what complicates things
 * - if transform region small, corner grips move out of the way
 *
 * iX iY, iP.x, iP.y - i indicates image space
 * tX tY, tP.x, tP.y - t indicates transform space
 *
 * Not sure if it can be used for navigable canvas. (especially if canvas rotates view)
 * Probably can't be extended for distort. Needs a different approach.
 *
 * --- DOM structure ---
 * rootEl {
 * 	transEl [
 * 		boundsEl
 * 		edges[]
 * 		corners[] - round grips in the corner of transform region
 * 		angleGrip
 * 	]
 * }
 *
 */
export class FreeTransform {

    // --- private ---
    private readonly transform: IFreeTransform; // coordinates and dimensions of transformation
    private isConstrained: boolean;
    private ratio: number; // aspect ratio of transform
    private readonly scale: number;
    private readonly scaled = { // scaled coordinates and dimensions for screen space
        x: 0,
        y: 0,
        width: 0,
        height: 0,
        corners: [{x: 0, y: 0}], // in transform space
    };
    private readonly minSnapDist = 7; // minimal snapping distance in px screen space
    private snappingEnabled: boolean;
    private readonly snapX: number[];
    private readonly snapY: number[];
    private readonly callback: (transform: IFreeTransform) => void;

    private readonly cornerCursors = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
    private readonly gripSize = 14;
    private readonly edgeSize = 10;

    private readonly rootEl: HTMLElement; // sits at origin of image
    private readonly transEl: HTMLElement; // at middle of transform. rotates
    private readonly boundsEl: HTMLElement; // draggable bounds rectangle with outline
    private readonly corners: TFreeTransformCorner[] = [];
    private keyListener: KeyListener;
    private boundsPointerListener: PointerListener; // BB.PointerListener
    private anglePointerListener: PointerListener; // BB.PointerListener
    private readonly edges: TFreeTransformEdge[] = [];
    private readonly angleGrip: {
        el: HTMLElement;
        x: number; // transform space
        y: number;
        snap: boolean;
        updateDOM: () => void;
    };

    private updateScaled (): void {
        this.scaled.x = this.transform.x * this.scale;
        this.scaled.y = this.transform.y * this.scale;
        this.scaled.width = this.transform.width * this.scale;
        this.scaled.height = this.transform.height * this.scale;
        this.scaled.corners = this.corners.map((item) => {
            return {x: item.x * this.scale, y: item.y * this.scale};
        });
    }

    /**
     * Returns snapped point, if ix, iy snaps. If no snapping, returns point unchanged.
     * both in image space
     *
     * @param iX - image space
     * @param iY - image space
     * @private
     */
    private snapCorner (iX: number, iY: number): IVector2D {
        if (!this.snappingEnabled) {
            return {x: iX, y: iY};
        }
        let dist: number;
        const snap: {
            x?: number;
            y?: number;
            dist: {
                x?: number;
                y?: number;
            };
        } = {
            x: undefined,
            y: undefined,
            dist: {
                x: undefined,
                y: undefined,
            },
        };
        for (let e = 0; e < this.snapX.length; e++) {
            dist = Math.abs(iX - this.snapX[e]);
            if (dist < this.minSnapDist / this.scale) {
                if (snap.x === undefined || dist < snap.dist.x!) {
                    snap.x = this.snapX[e];
                    snap.dist.x = dist;
                }
            }
        }
        for (let e = 0; e < this.snapY.length; e++) {
            dist = Math.abs(iY - this.snapY[e]);
            if (dist < this.minSnapDist / this.scale) {
                if (snap.y === undefined || dist < snap.dist.y!) {
                    snap.y = this.snapY[e];
                    snap.dist.y = dist;
                }
            }
        }

        if (snap.x === undefined && snap.y === undefined) {
            return {
                x: iX,
                y: iY,
            };
        }
        return {
            x: snap.x ?? iX,
            y: snap.y ?? iY,
        };
    }

    /**
     * If constrained return nearest corner pos that fits aspect ratio
     *
     * @param cornerIndex
     * @param iX
     * @param iY
     * @private
     */
    private constrainCorner (cornerIndex: number, iX: number, iY: number): IVector2D {
        if (!this.isConstrained) {
            return {
                x: iX,
                y: iY,
            };
        }
        const flip = this.transform.width * this.transform.height < 0 ? -1 : 1;
        return BB.projectPointOnLine(
            {x: this.transform.x, y: this.transform.y},
            toImageSpace(this.ratio, flip * ([0, 2].includes(cornerIndex) ? 1 : -1), this.transform),
            {x: iX, y: iY}
        );
    }

    /**
     * Update corners according to width height.
     * Not their DOM.
     */
    private updateCornerPositions (): void {
        this.corners[0].x = (-this.transform.width / 2); // top left
        this.corners[0].y = (-this.transform.height / 2);

        this.corners[1].x = (this.transform.width / 2); // top right
        this.corners[1].y = (-this.transform.height / 2);

        this.corners[2].x = (this.transform.width / 2); // bottom right
        this.corners[2].y = (this.transform.height / 2);

        this.corners[3].x = (-this.transform.width / 2); // bottom left
        this.corners[3].y = (this.transform.height / 2);
    }

    /**
     * If constrained and dragging an edge, restore aspect ratio
     * Updates corner positions.
     *
     * @param widthChanged
     * @param heightChanged
     * @private
     */
    private restoreRatio (widthChanged: boolean, heightChanged: boolean): void {
        if (!this.isConstrained) {
            return;
        }
        const angle90 = Math.abs(this.transform.angleDeg) % 90 === 0;
        const whSwapped = Math.abs(this.transform.angleDeg - 90) % 180 === 0;
        if (heightChanged && !widthChanged) {
            const newHeight = Math.abs(this.corners[3].y - this.corners[0].y);
            let newWidth = this.ratio * newHeight;
            if (angle90) {
                newWidth = (whSwapped ? this.transform.y % 1 : this.transform.x % 1) === 0 ?
                    BB.roundEven(newWidth) : BB.roundUneven(newWidth);
            }
            if (this.corners[1].x - this.corners[0].x < 0) {
                newWidth *= -1;
            }
            this.corners[0].x = -newWidth / 2;
            this.corners[3].x = -newWidth / 2;
            this.corners[1].x = newWidth / 2;
            this.corners[2].x = newWidth / 2;
        }
        if (!heightChanged && widthChanged) {
            const newWidth = Math.abs(this.corners[0].x - this.corners[1].x);
            let newHeight = newWidth / this.ratio;
            if (angle90) {
                newHeight = (whSwapped ? this.transform.x % 1 : this.transform.y % 1) === 0  ?
                    BB.roundEven(newHeight) : BB.roundUneven(newHeight);
            }
            if (this.corners[3].y - this.corners[0].y < 0) {
                newHeight *= -1;
            }
            this.corners[0].y = -newHeight / 2;
            this.corners[1].y = -newHeight / 2;
            this.corners[2].y = newHeight / 2;
            this.corners[3].y = newHeight / 2;
        }
    }

    /**
     * update transform based on corners
     * @private
     */
    private updateTransformViaCorners (): void {

        // calc transform center in image space
        const rot = BB.rotateAround(
            {x: 0, y: 0},
            {
                x: (this.corners[0].x + this.corners[1].x) / 2,
                y: (this.corners[0].y + this.corners[3].y) / 2,
            },
            this.transform.angleDeg
        );
        this.transform.x = rot.x + this.transform.x;
        this.transform.y = rot.y + this.transform.y;

        // update size
        this.transform.width = this.corners[1].x - this.corners[0].x;
        this.transform.height = this.corners[3].y - this.corners[0].y;

        // new center means corners changed their position
        this.updateCornerPositions();

        this.updateDOM();
    }

    /**
     * updates DOM according to transform
     * @param skipCallback
     */
    private updateDOM (skipCallback?: boolean): void {
        this.updateScaled();

        BB.css(this.transEl, {
            left: this.scaled.x + 'px',
            top: this.scaled.y + 'px',
            transformOrigin: '0 0',
            transform: 'rotate(' + this.transform.angleDeg + 'deg)',
        });

        BB.css(this.boundsEl, {
            width: Math.abs(this.scaled.width) + 'px',
            height: Math.abs(this.scaled.height) + 'px',
            left: Math.min(this.scaled.corners[0].x, this.scaled.corners[1].x) + 'px',
            top: Math.min(this.scaled.corners[0].y, this.scaled.corners[3].y) + 'px',
        });

        this.corners[0].updateDOM();
        this.corners[1].updateDOM();
        this.corners[2].updateDOM();
        this.corners[3].updateDOM();

        this.edges[0].updateDOM();
        this.edges[1].updateDOM();
        this.edges[2].updateDOM();
        this.edges[3].updateDOM();

        this.angleGrip.x = 0;
        this.angleGrip.y = (-Math.abs(this.transform.height * this.scale) / 2) - 20;
        this.angleGrip.updateDOM();
        if (!skipCallback) {
            if (this.callback) { // why should updateDOM trigger the callback?
                this.callback(copyTransform(this.transform));
            }
        }
    }

    // --- public ---
    constructor (
        params: {
            x: number; // center of transform region. image space
            y: number;
            width: number; // size of transform region. image space
            height: number;
            angleDeg: number; // angle of transform region. degrees

            isConstrained: boolean; // proportions constrained
            snapX: number[]; // where snapping along X axis. image space
            snapY: number[]; // where snapping along X axis. image space
            scale: number; // ratio of screen-space / image-space
            callback: (transform: IFreeTransform) => void;
        }
    ) {
        this.transform = { // coordinates and dimensions of transformation
            x: params.x,
            y: params.y,
            width: params.width,
            height: params.height,
            angleDeg: params.angleDeg,
        };

        this.isConstrained = params.isConstrained;

        this.snapX = params.snapX;
        this.snapY = params.snapY;
        this.callback = params.callback;
        this.scale = params.scale;
        this.snappingEnabled = true;
        this.ratio = this.transform.width / this.transform.height;

        this.rootEl = BB.el({
            className: 'kl-free-transform',
            css: {
                userSelect: 'none',
            },
        });
        this.transEl = BB.el({
            parent: this.rootEl,
            css: {
                position: 'absolute',
            },
        });

        this.boundsEl = BB.el({
            css: {
                position: 'absolute',
                cursor: 'move',
                boxShadow: 'rgba(255, 255, 255, 0.5) 0 0 0 1px inset, rgba(0, 0, 0, 0.5) 0 0 0 1px',
            },
        });

        const pointerRemainder = {
            x: 0,
            y: 0,
        };
        function resetRemainder (): void {
            pointerRemainder.x = 0;
            pointerRemainder.y = 0;
        }
        this.keyListener = new BB.KeyListener({});

        let boundsStartP = {
            x: 0,
            y: 0,
        };
        this.boundsPointerListener = new BB.PointerListener({
            target: this.boundsEl,
            fixScribble: true,
            onPointer: (event) => {
                event.eventPreventDefault();
                if (event.type === 'pointerdown') {
                    boundsStartP = {x: this.transform.x, y: this.transform.y};
                }
                if (event.type === 'pointermove' && event.button === 'left') {
                    this.transform.x = boundsStartP.x + (event.pageX - event.downPageX!) / this.scale;
                    this.transform.y = boundsStartP.y + (event.pageY - event.downPageY!) / this.scale;

                    let dist: number;
                    let snap: {
                        x?: number;
                        y?: number;
                        distX: number;
                        distY: number;
                    } = {
                        distX: -1,
                        distY: -1,
                    };
                    if (this.snappingEnabled) {
                        let i;
                        for (i = 0; i < this.snapX.length; i++) {
                            dist = Math.abs(this.transform.x - this.snapX[i]);
                            if (dist < this.minSnapDist / this.scale) {
                                if (snap.x === undefined || dist < snap.distX) {
                                    snap.x = this.snapX[i];
                                    snap.distX = dist;
                                }
                            }
                        }
                        for (i = 0; i < this.snapY.length; i++) {
                            dist = Math.abs(this.transform.y - this.snapY[i]);
                            if (dist < this.minSnapDist / this.scale) {
                                if (snap.y === undefined || dist < snap.distY) {
                                    snap.y = this.snapY[i];
                                    snap.distY = dist;
                                }
                            }
                        }

                        let iP;
                        for (i = 0; i < 4; i++) {
                            iP = toImageSpace(this.corners[i].x, this.corners[i].y, this.transform);
                            let j;
                            for (j = 0; j < this.snapX.length; j++) {
                                dist = Math.abs(iP.x - this.snapX[j]);
                                if (dist < this.minSnapDist / this.scale) {
                                    if (snap.x === undefined || dist < snap.distX) {
                                        snap.x = this.snapX[j] - (iP.x - this.transform.x);
                                        snap.distX = dist;
                                    }
                                }
                            }
                            for (j = 0; j < this.snapY.length; j++) {
                                dist = Math.abs(iP.y - this.snapY[j]);
                                if (dist < this.minSnapDist / this.scale) {
                                    if (snap.y === undefined || dist < snap.distY) {
                                        snap.y = this.snapY[j] - (iP.y - this.transform.y);
                                        snap.distY = dist;
                                    }
                                }
                            }
                        }
                    }
                    if (this.keyListener.getComboStr() === 'shift') {
                        let projected = BB.projectPointOnLine(
                            {x: 0, y: boundsStartP.y},
                            {x: 10, y: boundsStartP.y},
                            {x: this.transform.x, y: this.transform.y});
                        let dist = BB.dist(projected.x, projected.y, this.transform.x, this.transform.y);
                        snap = {
                            x: projected.x,
                            y: projected.y,
                            distX: dist,
                            distY: dist,
                        };

                        projected = BB.projectPointOnLine(
                            {x: boundsStartP.x, y: 0},
                            {x: boundsStartP.x, y: 10},
                            {x: this.transform.x, y: this.transform.y});
                        dist = BB.dist(projected.x, projected.y, this.transform.x, this.transform.y);
                        if (dist < snap.distX) {
                            snap = {
                                x: projected.x,
                                y: projected.y,
                                distX: dist,
                                distY: dist,
                            };
                        }

                        projected = BB.projectPointOnLine(
                            {x: boundsStartP.x, y: boundsStartP.y},
                            {x: boundsStartP.x + 1, y: boundsStartP.y + 1},
                            {x: this.transform.x, y: this.transform.y});
                        dist = BB.dist(projected.x, projected.y, this.transform.x, this.transform.y);
                        if (dist < snap.distX) {
                            snap = {
                                x: projected.x,
                                y: projected.y,
                                distX: dist,
                                distY: dist,
                            };
                        }

                        projected = BB.projectPointOnLine(
                            {x: boundsStartP.x, y: boundsStartP.y},
                            {x: boundsStartP.x + 1, y: boundsStartP.y - 1},
                            {x: this.transform.x, y: this.transform.y});
                        dist = BB.dist(projected.x, projected.y, this.transform.x, this.transform.y);
                        if (dist < snap.distX) {
                            snap = {
                                x: projected.x,
                                y: projected.y,
                                distX: dist,
                                distY: dist,
                            };
                        }
                    }
                    if (snap.x != undefined) {
                        this.transform.x = snap.x;
                    }
                    if (snap.y != undefined) {
                        this.transform.y = snap.y;
                    }

                    // snap to pixels
                    if (Math.abs(this.transform.angleDeg) % 90 === 0) {
                        snapToPixel(this.transform);
                        this.updateCornerPositions();
                    }

                    this.updateDOM();
                }
            },
        });

        for (let i = 0; i < 4; i++) {
            ((i) => {
                const g = this.corners[i] = {
                    i: i,
                    el: BB.el({
                        css: {
                            width: this.gripSize + 'px',
                            height: this.gripSize + 'px',
                            background: '#fff',
                            /*background: [
                                '#ff0000',
                                '#00ff00',
                                '#0000ff',
                                '#ff00ff',
                            ][i],*/
                            borderRadius: this.gripSize + 'px',
                            position: 'absolute',
                            boxShadow: 'inset 0 0 0 2px #000',
                        },
                    }) as HTMLElement,
                    x: 0,
                    y: 0,
                    virtualPos: {
                        x: 0,
                        y: 0,
                    },
                } as TFreeTransformCorner;
                g.updateDOM = (): void => {

                    // grip position
                    // if it gets small: slightly offset grips, so easier to handle
                    const offsetArr = [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(item => {
                        item[0] *= this.transform.width > 0 ? 1 : -1;
                        item[1] *= this.transform.height > 0 ? 1 : -1;
                        return item;
                    });
                    const tinyOffset = Math.abs(this.scaled.width) < 20 || Math.abs(this.scaled.height) < 20 ? 10 : 0;

                    BB.css(g.el, {
                        left: (this.scaled.corners[g.i].x - this.gripSize / 2 + offsetArr[i][0] * tinyOffset) + 'px',
                        top: (this.scaled.corners[g.i].y - this.gripSize / 2 + offsetArr[i][1] * tinyOffset) + 'px',
                    });

                    // cursor
                    let angle = BB.pointsToAngleDeg(
                        {
                            x: this.transform.x,
                            y: this.transform.y,
                        },
                        toImageSpace(g.x, g.y, this.transform),
                    ) + 135; // offset so nw is 0
                    while (angle < 0) {
                        angle += 360;
                    }
                    const index = Math.round(angle / 45) % this.cornerCursors.length;
                    BB.css(g.el, {
                        cursor: this.cornerCursors[index] + '-resize',
                    });
                };

                g.pointerListener = new BB.PointerListener({
                    target: this.corners[i].el,
                    fixScribble: true,
                    onPointer: (event) => {
                        event.eventPreventDefault();
                        if (event.type === 'pointerdown' && event.button === 'left') {
                            this.corners[i].virtualPos = toImageSpace(this.corners[i].x, this.corners[i].y, this.transform);

                        } else if (event.type === 'pointermove' && event.button === 'left') {
                            this.corners[i].virtualPos.x += event.dX / this.scale;
                            this.corners[i].virtualPos.y += event.dY / this.scale;

                            let iP = {
                                x: this.corners[i].virtualPos.x,
                                y: this.corners[i].virtualPos.y,
                            };
                            iP = this.constrainCorner(i, iP.x, iP.y);
                            if (!this.isConstrained) {
                                iP = this.snapCorner(iP.x, iP.y);
                            }

                            if (Math.abs(this.transform.angleDeg) % 90 === 0) {
                                iP.x = Math.round(iP.x);
                                iP.y = Math.round(iP.y);
                            }

                            const tP = toTransformSpace(iP.x, iP.y, this.transform);

                            const dX = tP.x - this.corners[i].x;
                            const dY = tP.y - this.corners[i].y;
                            this.corners[i].x = tP.x;
                            this.corners[i].y = tP.y;

                            let indexes: number[] = [];
                            if (i === 0) { // top left
                                indexes = [3, 1, 2];
                            } else if (i === 1) { // top right
                                indexes = [2, 0, 3];
                            } else if (i === 2) { // bottom right
                                indexes = [1, 3, 0];
                            } else if (i === 3) { // bottom left
                                indexes = [0, 2, 1];
                            }

                            this.corners[indexes[0]].x = this.corners[i].x;
                            this.corners[indexes[1]].y = this.corners[i].y;
                            if (this.keyListener.isPressed('shift')) {
                                this.corners[indexes[2]].x -= dX;
                                this.corners[indexes[2]].y -= dY;
                                this.corners[indexes[1]].x = this.corners[indexes[2]].x;
                                this.corners[indexes[0]].y = this.corners[indexes[2]].y;
                            }

                            this.updateTransformViaCorners();

                        }
                    },
                });

            })(i);
        }

        this.updateCornerPositions();
        this.updateScaled();

        let isInverted: boolean;
        for (let i = 0; i < 4; i++) {
            ((i): void => {
                this.edges[i] = {
                    el: BB.el({
                        css: {
                            width: this.edgeSize + 'px',
                            height: this.edgeSize + 'px',
                            /*background: [
                                '#ff000044',
                                '#00ff0044',
                                '#0000ff44',
                                '#ff00ff44',
                            ][i],*/
                            position: 'absolute',
                        },
                    }) as HTMLElement,
                } as TFreeTransformEdge;
                const g = this.edges[i];
                g.updateDOM = () => {
                    if (i === 0) {
                        BB.css(g.el, {
                            left: Math.min(this.scaled.corners[0].x, this.scaled.corners[1].x) + 'px',
                            top: (Math.min(this.scaled.corners[0].y, this.scaled.corners[3].y) - this.edgeSize) + 'px',
                            width: Math.abs(this.scaled.width) + 'px',
                            height: this.edgeSize + 'px',
                        });
                    } else if (i === 1) {
                        BB.css(g.el, {
                            left: Math.max(this.scaled.corners[0].x, this.scaled.corners[1].x) + 'px',
                            top: Math.min(this.scaled.corners[1].y, this.scaled.corners[2].y) + 'px',
                            width: this.edgeSize + 'px',
                            height: Math.abs(this.scaled.height) + 'px',
                        });
                    } else if (i === 2) {
                        BB.css(g.el, {
                            left: Math.min(this.scaled.corners[3].x, this.scaled.corners[2].x) + 'px',
                            top: Math.max(this.scaled.corners[0].y,this.scaled.corners[3].y) + 'px',
                            width: Math.abs(this.scaled.width) + 'px',
                            height: this.edgeSize + 'px',
                        });
                    } else if (i === 3) {
                        BB.css(g.el, {
                            left: (Math.min(this.scaled.corners[0].x, this.scaled.corners[1].x) - this.edgeSize) + 'px',
                            top: Math.min(this.scaled.corners[0].y, this.scaled.corners[3].y) + 'px',
                            width: this.edgeSize + 'px',
                            height: Math.abs(this.scaled.height) + 'px',
                        });
                    }
                    let angleOffset = Math.round(this.transform.angleDeg / 45);
                    while (angleOffset < 0) {
                        angleOffset += 8;
                    }
                    angleOffset = (i * 2 + 1 + angleOffset) % this.cornerCursors.length;
                    g.el.style.cursor = this.cornerCursors[angleOffset] + '-resize';

                };

                const isVertical = [0, 2].includes(i);
                g.pointerListener = new BB.PointerListener({
                    target: this.edges[i].el,
                    fixScribble: true,
                    onPointer: (event) => {
                        event.eventPreventDefault();
                        if (event.type === 'pointerdown' && event.button === 'left') {
                            if (isVertical) { // top bottom
                                isInverted = this.corners[0].y >= this.corners[3].y;
                            } else { // left right
                                isInverted = this.corners[0].x >= this.corners[1].x;
                            }
                            resetRemainder();
                        }
                        if (event.type === 'pointermove' && event.button === 'left') {
                            const tfD = BB.rotateAround({x: 0, y: 0},
                                {x: event.dX / this.scale, y: event.dY / this.scale},
                                -this.transform.angleDeg);
                            let ti = {
                                dX: tfD.x,
                                dY: tfD.y,
                            };
                            if (Math.abs(this.transform.angleDeg) % 90 === 0) {
                                ti = BB.intDxy(pointerRemainder, tfD.x, tfD.y);
                            }

                            let indexes: number[] = [];
                            if (i === 0) { // top
                                indexes = [2, 3, 0, 1];
                            } else if (i === 1) { // right
                                indexes = [0, 3, 1, 2];
                            } else if (i === 2) { // bottom
                                indexes = [0, 1, 2, 3];
                            } else if (i === 3) { // left
                                indexes = [1, 2, 0, 3];
                            }

                            const dimension = isVertical ? 'y' : 'x';
                            const d = isVertical ? ti.dY : ti.dX;

                            if (isInverted) {
                                this.corners[indexes[0]][dimension] += d;
                                this.corners[indexes[1]][dimension] += d;
                            } else {
                                this.corners[indexes[2]][dimension] += d;
                                this.corners[indexes[3]][dimension] += d;
                            }
                            if (this.keyListener.isPressed('shift')) {
                                if (isInverted) {
                                    this.corners[indexes[2]][dimension] -= d;
                                    this.corners[indexes[3]][dimension] -= d;
                                } else {
                                    this.corners[indexes[0]][dimension] -= d;
                                    this.corners[indexes[1]][dimension] -= d;
                                }
                            }

                            if (isVertical) { // top bottom
                                this.restoreRatio(false, true);
                            } else { // left right
                                this.restoreRatio(true, false);
                            }

                            this.updateTransformViaCorners();

                        }
                    },
                });

            })(i);
        }

        this.angleGrip = {
            el: BB.el({
                css: {
                    cursor: 'url(' + rotateImg + ') 10 10, move',
                    width: this.gripSize + 'px',
                    height: this.gripSize + 'px',
                    background: '#0ff',
                    borderRadius: this.gripSize + 'px',
                    position: 'absolute',
                    boxShadow: 'inset 0 0 0 2px #000',
                },
            }),
            x: 0,
            y: 0,
            snap: false,
            updateDOM: () => {
                BB.css(this.angleGrip.el, {
                    left: (this.angleGrip.x - this.gripSize / 2) + 'px',
                    top: (this.angleGrip.y - this.gripSize / 2) + 'px',
                });
            },
        };
        BB.el({
            parent: this.angleGrip.el,
            css: {
                width: '2px',
                height: '13px',
                left: (this.gripSize / 2 - 1) + 'px',
                top: this.gripSize + 'px',
                background: '#0ff',
                position: 'absolute',
            },
        });

        this.anglePointerListener = new BB.PointerListener({
            target: this.angleGrip.el,
            fixScribble: true,
            onPointer: (event) => {
                event.eventPreventDefault();
                if (event.type === 'pointermove' && event.button === 'left') {

                    const bounds = this.rootEl.getBoundingClientRect();
                    const offset = {
                        x: bounds.left - this.rootEl.scrollLeft,
                        y: bounds.top - this.rootEl.scrollTop,
                    };
                    const iP = {x: (event.clientX - offset.x) / this.scale, y: (event.clientY - offset.y) / this.scale};

                    const a = BB.pointsToAngleDeg({x: this.transform.x, y: this.transform.y}, iP) + 90;
                    this.transform.angleDeg = a;
                    const snapDeg = Math.round(a / 360 * 8) * 45;
                    if (this.keyListener.getComboStr() === 'shift') {
                        this.transform.angleDeg = snapDeg;
                    } else if (this.snappingEnabled && Math.abs(snapDeg - a) < 8) {
                        this.transform.angleDeg = snapDeg;
                    }
                    this.updateDOM();

                }
                if (event.type === 'pointerup') {
                    if (Math.abs(this.transform.angleDeg) % 90 === 0) {
                        snapToPixel(this.transform);
                        this.updateCornerPositions();
                        this.updateDOM();
                    }
                }
            },
        });

        snapToPixel(this.transform);
        this.updateDOM(true);
        BB.append(this.transEl, [
            this.boundsEl,
            this.edges[0].el,
            this.edges[1].el,
            this.edges[2].el,
            this.edges[3].el,
            this.corners[0].el,
            this.corners[1].el,
            this.corners[2].el,
            this.corners[3].el,
            this.angleGrip.el,
        ]);

    }

    getTransform (): IFreeTransform {
        return copyTransform(this.transform);
    }

    setConstrained (b: boolean): void {
        this.isConstrained = b;
        if (b && this.transform.width !== 0 && this.transform.height !== 0) {
            this.ratio = Math.abs(this.transform.width / this.transform.height);
        }
    }

    setSnapping (s: boolean): void {
        this.snappingEnabled = s;
    }

    setPos (p: IVector2D): void {
        this.transform.x = p.x;
        this.transform.y = p.y;
        this.updateDOM(true);
    }

    move (dX: number, dY: number): void {
        this.transform.x += dX;
        this.transform.y += dY;
        this.updateDOM(false);
    }

    setSize (w: number, h: number): void {
        this.transform.width = w;
        this.transform.height = h;
        if (Math.abs(this.transform.angleDeg) % 90 === 0) {
            snapToPixel(this.transform);
        }
        this.updateCornerPositions();
        this.updateDOM(false);
    }

    setAngleDeg (a: number): void {
        this.transform.angleDeg = a;
        if (Math.abs(this.transform.angleDeg) % 90 === 0) {
            snapToPixel(this.transform);
            this.updateCornerPositions();
        }
        this.updateDOM(true);
    }

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

    getRatio (): number {
        return this.ratio;
    }

    destroy (): void {
        this.keyListener.destroy();
        this.boundsPointerListener.destroy();
        this.corners[0].pointerListener.destroy();
        this.corners[1].pointerListener.destroy();
        this.corners[2].pointerListener.destroy();
        this.corners[3].pointerListener.destroy();
        this.edges[0].pointerListener.destroy();
        this.edges[1].pointerListener.destroy();
        this.edges[2].pointerListener.destroy();
        this.edges[3].pointerListener.destroy();
        this.anglePointerListener.destroy();
    }

}


code:
import {BB} from '../../../bb/bb';
import {ImageToggle} from './image-toggle';

type TOptionItem<IdType> = {
    id: IdType;
    title: string;
    image: string;
    darkInvert?: boolean;
};

/**
 * Radio input group. each one has an image
 */
export class ImageRadioList<IdType> {

    private readonly rootEl: HTMLElement;
    private activeIndex: number;
    private readonly optionArr: {
        id: IdType;
        radioEl: ImageToggle;
    }[];

    // --- public ---
    constructor (
        p: {
            optionArr: TOptionItem<IdType>[];
            initId: string;
            onChange: (id: IdType) => void;
        }
    ) {
        this.rootEl = BB.el({
            className: 'image-radio-wrapper',
            css: {
                display: 'flex',
            },
        });

        this.optionArr = [];

        const select = (index: number, id: IdType): void => {
            this.activeIndex = index;
            for (let i = 0; i < this.optionArr.length; i++) {
                this.optionArr[i].radioEl.setValue(i === this.activeIndex);
            }
            p.onChange(id);
        };

        let initialIndex;
        const createOption = (index: number, o: TOptionItem<IdType>): {
            id: IdType;
            radioEl: ImageToggle;
        } => {
            if (o.id === p.initId) {
                initialIndex = index;
            }
            const radioEl = new ImageToggle({
                image: o.image,
                title: o.title,
                initValue: o.id === p.initId,
                isRadio: true,
                onChange: () => {
                    select(index, o.id);
                },
                darkInvert: o.darkInvert,
            });
            this.rootEl.append(radioEl.getElement());

            return {
                id: o.id,
                radioEl,
            };
        };

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

        if (initialIndex === undefined) {
            throw new Error('initId not in optionArr');
        }
        this.activeIndex = initialIndex;
    }

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

    getValue (): IdType {
        return this.optionArr[this.activeIndex].id;
    }

    destroy (): void {
        this.optionArr.forEach(item => {
            item.radioEl.destroy();
        });
    }
}


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

/**
 * Toggle button with an image
 */
export class ImageToggle {

    private readonly rootEl: HTMLElement;
    private isActive: boolean;

    private update (): void {
        this.rootEl.classList.toggle('image-toggle-active', this.isActive);
    }

    // --- public ---
    constructor (
        p: {
            initValue: boolean;
            title: string;
            isRadio?: boolean; // if true, can't click when active
            onChange: (b: boolean) => void;
            image: string;
            darkInvert?: boolean;
        }
    ) {
        this.isActive = !!p.initValue;
        this.rootEl = BB.el({
            className: 'image-toggle',
            title: p.title,
            content: BB.el({
                className: 'image-toggle__im' + (p.darkInvert ? ' dark-invert' : ''),
                css: {
                    backgroundImage: "url('" + p.image + "')",
                },
            }),
            onClick: (e) => {
                e.preventDefault();
                if (p.isRadio && this.isActive) {
                    return;
                }
                this.isActive = !this.isActive;
                this.update();
                p.onChange(this.isActive);
            },
        });

        this.update();
    }

    // --- interface ---
    setValue (b: boolean): void {
        this.isActive = !!b;
        this.update();
    }

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

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

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


code:
import {BB} from '../../../bb/bb';
import {IKeyString} from '../../../bb/bb-types';
import {KeyListener} from '../../../bb/input/key-listener';

type TInputType = 'button' | 'checkbox' | 'number' | 'text' | 'color';

export type TInputParams = {
    type?: TInputType; // default: text
    init: string | number;
    title?: string;
    label?: string | HTMLElement;

    // for type: number
    min?: number;
    max?: number;
    step?: number;

    onChange?: (v: string) => void;

    doScrollWithoutFocus?: boolean; // default: false
    doResetIfInvalid?: boolean; // default: false

    css?: IKeyString;
};

export class Input {
    private readonly rootEl: HTMLLabelElement;
    private readonly input: HTMLInputElement;
    private readonly type: TInputType;
    private readonly pointerListener;
    private readonly keyListener: KeyListener = {destroy: () => {}} as KeyListener;

    // ---- public ----
    constructor (p: TInputParams) {
        this.rootEl = BB.el({
            tagName: 'label',
            content: p.label,
            css: {
                display: 'flex',
                alignItems: 'center',
                gap: '5px',
            },
        });

        this.input = BB.el({
            tagName: 'input',
            parent: this.rootEl,
            title: p.title,
        });

        this.input.type;

        this.type = p.type ?? 'text';
        try {
            this.input.type = this.type;
        } catch(e) {
            // ie can't deal with number
        }

        const stepSize = p.step ?? 1;

        if (this.type === 'number') {
            if (p.min !== undefined) {
                this.input.min = '' + p.min;
            }
            if (p.max !== undefined) {
                this.input.max = '' + p.max;
            }
            if (p.step !== undefined) {
                this.input.step = '' + stepSize;
            }
        }

        this.input.value = '' + p.init;

        let lastValidValue = this.input.value;

        /**
         * return true if not changed
         */
        const handleChange = (): boolean => {
            const oldVal = lastValidValue;
            let newValue = this.input.value;
            if (p.doResetIfInvalid) {
                let didChange = false;
                if (newValue === '') {
                    newValue = lastValidValue;
                    didChange = true;
                }
                if (p.min !== undefined && parseFloat(newValue) < p.min) {
                    newValue = '' + p.min;
                    didChange = true;
                }
                if (p.max !== undefined && parseFloat(newValue) > p.max) {
                    newValue = '' + p.max;
                    didChange = true;
                }
                if (didChange) {
                    this.input.value = '' + newValue;
                }
            }

            lastValidValue = newValue;

            return oldVal !== newValue;
        };

        if (p.onChange) {
            const onChange = p.onChange;
            this.input.onchange = () => {
                handleChange() && onChange(this.input.value);
            };
        }
        if (p.css) {
            BB.css(this.input, p.css);
        }

        if (p.doScrollWithoutFocus && p.type === 'number' && p.onChange) {
            const onChange = p.onChange;
            this.keyListener = new BB.KeyListener({});
            this.pointerListener = new BB.PointerListener({
                target: this.input,
                onWheel: (e) => {
                    /*if (document.hasFocus()) { // what was the point of this?
                        return;
                    }*/
                    const fac = this.keyListener.isPressed('shift') ? 4 : 1;
                    this.input.value = '' + (parseFloat(this.input.value) - e.deltaY * stepSize * fac);
                    handleChange() && onChange(this.input.value);
                },
            });
        }
    }

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

    getValue (): string {
        return this.input.value;
    }

    setValue (v: string | number): void {
        this.input.value = '' + v;
    }

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

// todo replace instances with class
export const input = function (params: {
    type?: TInputType; // default text
    min?: number;
    max?: number;
    callback: (val: string) => void;
    init: string | number;
    css?: IKeyString;
}) {
    const result = document.createElement('input');
    if (params.type) {
        try {
            result.type = params.type;
        } catch(e) {
            /* empty */
            // ie can't deal with number
        }
    } else {
        result.type = 'text';
    }
    if (params.min !== undefined) {
        result.min = '' + params.min;
    }
    if (params.max !== undefined) {
        result.max = '' + params.max;
    }
    result.value = '' + params.init;
    if (params.callback) {
        result.onchange = function () {
            params.callback(result.value);
        };
    }
    if (params.css) {
        BB.css(result, params.css);
    }

    return result;
};


code:
import {BB} from '../../../bb/bb';
import {calcSliderFalloffFactor} from './slider-falloff';
import {IRGB} from '../../kl-types';
import {PointerListener} from '../../../bb/input/pointer-listener';
import {HSV} from '../../../bb/color/color';

/**
 * a small color slider
 */
export class KlColorSliderSmall {

    private readonly rootEl: HTMLElement;
    private color: HSV;
    private readonly svPointerListener: PointerListener;
    private readonly hPointerListener: PointerListener;
    private svPointerId: number | null;
    private hPointerId: number | null;
    private readonly width: number;
    private readonly heightSV: number;
    private readonly pointerSV: HTMLElement;
    private readonly pointerH: HTMLElement;
    private readonly canvasSV: HTMLCanvasElement;

    private updateSV (): void {
        const ctx = BB.ctx(this.canvasSV);
        if(!ctx) {
            throw new Error('couldnt create canvas');
        }
        for (let i = 0; i < this.canvasSV.height; i += 1) {
            const gradient1 = ctx.createLinearGradient(0, 0, this.canvasSV.width, 0);

            const colleft = BB.ColorConverter.toRGB(new BB.HSV(this.color.h, 1, 100 - (i / this.canvasSV.height * 100.0)));
            const colright = BB.ColorConverter.toRGB(new BB.HSV(this.color.h, 100, 100 - (i / this.canvasSV.height * 100.0)));
            gradient1.addColorStop(0, '#' + BB.ColorConverter.toHexString(colleft));
            gradient1.addColorStop(1, '#' + BB.ColorConverter.toHexString(colright));
            ctx.fillStyle = '#ff0000'; //needed for chrome...otherwise alpha problem
            ctx.fillStyle = gradient1;
            ctx.fillRect(0, i, this.canvasSV.width, 1);
        }
    }

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

    private updateHPointer (): void {
        this.pointerH.style.left = (this.color.h / 359.999 * this.width - 1) + 'px';
    }

    // ----- public ----
    constructor (
        p: {
            width: number;
            heightSV: number; // height of saturation/value
            heightH: number; // height of hue
            color: IRGB;
            callback: (c: IRGB) => void;
        }
    ) {
        this.rootEl = BB.el({
            css: {
                width: p.width + 'px',
                position: 'relative',
                overflow: 'hidden',
                userSelect: 'none',
            },
        });
        this.rootEl.oncontextmenu = (e) => {
            e.preventDefault();
        };
        this.color = BB.ColorConverter.toHSV(new BB.RGB(p.color.r, p.color.g, p.color.b));
        this.width = p.width;
        this.heightSV = p.heightSV;

        this.canvasSV = BB.canvas(10, 10);
        BB.css(this.canvasSV, {
            width: this.width + 'px',
            height: this.heightSV + 'px',
            cursor: 'crosshair',
        });

        this.updateSV();

        const canvasH = BB.canvas(p.width, p.heightH);
        canvasH.style.cursor = 'ew-resize';
        (() => {
            const ctx = BB.ctx(canvasH);

            const gradH = ctx.createLinearGradient(0, 0, p.width, 0);
            for (let i = 0; i < 1; i += 0.01) {
                const col = BB.ColorConverter.toRGB(new BB.HSV(i * 360, 100, 100));
                gradH.addColorStop(i, 'rgba(' + parseInt('' + col.r) + ', ' + parseInt('' + col.g) + ', ' + parseInt('' + col.b) + ', 1)');
            }
            ctx.fillStyle = gradH;
            ctx.fillRect(0, 0, p.width, p.heightH);

        })();
        BB.css(this.canvasSV, {
            width: p.width + 'px',
            height: p.heightSV + 'px',
            overflow: 'hidden',
            position: 'relative',
        });
        this.canvasSV.style.cssFloat = 'left';
        canvasH.style.cssFloat = 'left';

        this.rootEl.append(this.canvasSV, canvasH);

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

        this.pointerH = BB.el({
            parent: this.rootEl,
            css: {
                width: '0',
                height: p.heightH + 'px',
                borderLeft: '1px solid #fff',
                borderRight: '1px solid #000',
                position: 'absolute',
                top: p.heightSV + 'px',
                pointerEvents: 'none',
            },
        });

        this.updateSVPointer();
        this.updateHPointer();

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

        this.svPointerId = null;
        this.svPointerListener = new BB.PointerListener({
            target: this.canvasSV,
            fixScribble: true,
            onPointer: (event) => {

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

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

                    this.svPointerId = event.pointerId;
                    if (event.button === 'left') {

                        virtualHSV.s = event.relX / p.width * 100;
                        virtualHSV.v = 100 - event.relY / p.heightSV * 100;

                        this.color = new BB.HSV(this.color.h, virtualHSV.s, virtualHSV.v);

                        this.updateSVPointer();
                        p.callback(BB.ColorConverter.toRGB(this.color));
                    } else {
                        virtualHSV.s = this.color.s;
                        virtualHSV.v = this.color.v;
                    }
                }

                if (event.type === 'pointermove' && ['left', 'right'].includes('' + event.button) && this.svPointerId === event.pointerId) {

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

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

                    this.color = new BB.HSV(this.color.h, virtualHSV.s, virtualHSV.v);
                    this.updateSVPointer();
                    p.callback(BB.ColorConverter.toRGB(this.color));

                }
                if (event.type === 'pointerup') {
                    this.svPointerId = null;
                }

            },
        });

        this.hPointerId = null;
        this.hPointerListener = new BB.PointerListener({
            target: canvasH,
            fixScribble: true,
            onPointer: (event) => {

                if (event.type === 'pointerdown') {
                    this.hPointerId = event.pointerId;
                    if (event.button === 'left') {

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

                        this.color = new BB.HSV(virtualHSV.h, this.color.s, this.color.v);
                        this.updateSV();
                        this.updateHPointer();
                        p.callback(BB.ColorConverter.toRGB(this.color));
                    } else {
                        virtualHSV.h = this.color.h;
                    }
                }

                if (event.type === 'pointermove' && ['left', 'right'].includes('' + event.button) && this.hPointerId === event.pointerId) {

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

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

                    if (event.button === 'right') {
                        virtualHSV.h = virtualHSV.h % 359.99;
                        if (virtualHSV.h < 0) {
                            virtualHSV.h += 359.99;
                        }
                    }
                    virtualHSV.h = Math.min(359.99, virtualHSV.h);
                    this.color = new BB.HSV(virtualHSV.h, this.color.s, this.color.v);
                    this.updateSV();
                    this.updateHPointer();
                    p.callback(BB.ColorConverter.toRGB(this.color));

                }
                if (event.type === 'pointerup') {
                    this.hPointerId = null;
                }
            },
        });

        const cleardiv = BB.el({
            parent: this.rootEl,
            css: {
                clear: 'both',
            },
        });
    }

    // ---- interface ----
    setColor (c: IRGB): void {
        this.color = BB.ColorConverter.toHSV(new BB.RGB(c.r, c.g, c.b));
        this.updateSV();
        this.updateSVPointer();
        this.updateHPointer();
    }

    getColor (): IRGB {
        return BB.ColorConverter.toRGB(this.color);
    }

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

    destroy (): void {
        this.svPointerListener.destroy();
        this.hPointerListener.destroy();
    }

    end (): void {
        this.svPointerId = null;
        this.hPointerId = null;
    }
}