code:
import {BB} from '../../bb/bb';
import {floodFillBits} from '../image-operations/flood-fill';
import {drawShape} from '../image-operations/shape-tool';
import {TRenderTextParam, renderText} from '../image-operations/render-text';
import {IGradient, IKlProject, IRGB, IShapeToolObject, TFillSampling, TKlCanvasLayer, TMixMode} from '../kl-types';
import {DecoyKlHistory, KlHistoryInterface, THistoryActions} from '../history/kl-history';
import {drawProject} from './draw-project';
import {LANG} from '../../language/language';
import {drawGradient} from '../image-operations/gradient-tool';
import {nullToUndefined} from '../../bb/base/base';

export type TKlCanvasHistoryEntry = THistoryActions<'canvas', KlCanvas>;

const allowedMixModes = [
    'source-over',
    'darken',
    'multiply',
    'color-burn',
    'lighten',
    'screen',
    'color-dodge',
    'overlay',
    'soft-light',
    'hard-light',
    'difference',
    'exclusion',
    'hue',
    'saturation',
    'color',
    'luminosity',
] as const;

export const MAX_LAYERS = 16;

export interface KlCanvasLayer extends HTMLCanvasElement {
    name: string;
    mixModeStr: TMixMode;
    isVisible: boolean;
    opacity: number;
    compositeObj?: {
        draw: (ctx: CanvasRenderingContext2D) => void;
    };
    index: number; // certain brushes need to know
}

export type KlCanvasContext = CanvasRenderingContext2D & {
    canvas: KlCanvasLayer;
};

/**
 * The image/canvas that the user paints on
 * Has layers. layers have names and opacity.
 *
 * Interacts with the history you specify (for undo/redo)
 */
export class KlCanvas {

    // todo changeListener consistent concept
    // todo history interaction, consistent concept - currently have to look at code to know if something pushes on history

    private isDestroyed = false;
    private width: number;
    private height: number;
    private layerCanvasArr: KlCanvasLayer[];
    private pickCanvas: HTMLCanvasElement; // canvas to draw into for color picker
    private history: KlHistoryInterface;
    private changeListenerArr:  (() => void)[]; // subscribers get notified when opacity changes

    private init (w: number, h: number): void {
        if (!w || !h || isNaN(w) || isNaN(h) || w < 1 || h < 1) {
            throw new Error('init - invalid canvas size: ' + w + ', ' + h);
        }
        this.width = w;
        this.height = h;
    }

    private emitChange (): void {
        // some changes don't get captured by the history - e.g. changing opacity as the user drags the slider
        this.changeListenerArr.forEach(item => item());
    }

    private updateIndices (): void {
        this.layerCanvasArr.forEach((item, index) => {
            item.index = index;
        });
    }

    // ---- public ----

    constructor (
        params: {
            projectObj: IKlProject;
        } | {
            // creates blank KlCanvas, 0 layers
            width: number;
            height: number;
        } | {
            copy: KlCanvas;
        },
        private layerNrOffset: number = 0,
    ) {
        this.layerCanvasArr = [];
        this.pickCanvas = BB.canvas(1, 1);
        this.history = new DecoyKlHistory();
        if ('copy' in params) {
            this.width = 1;
            this.height = 1;
        } else {
            if ('width' in params && 'height' in params) {
                this.width = params.width;
                this.height = params.height;
            } else {
                this.width = 1;
                this.height = 1;
            }
        }
        this.init(this.width, this.height);

        this.changeListenerArr = [];

        if ('copy' in params) {
            try {
                this.copy(params.copy);
            } catch (e) {
                this.destroy();
                throw e;
            }
        } else if ('projectObj' in params) {
            const inLayers = [...params.projectObj.layers];
            this.init(params.projectObj.width, params.projectObj.height);

            if (!inLayers.length) {
                throw new Error('project.layers needs at least 1 layer');
            }

            for (let i = 0; i < inLayers.length; i++) {
                const mixModeStr = inLayers[i].mixModeStr;
                if (mixModeStr && !allowedMixModes.includes(mixModeStr)) {
                    throw new Error('unknown mixModeStr ' + inLayers[i].mixModeStr);
                }

                this.addLayer();
                this.layerOpacity(i, inLayers[i].opacity);
                this.layerCanvasArr[i].name = inLayers[i].name;
                this.layerCanvasArr[i].isVisible = inLayers[i].isVisible;
                this.layerCanvasArr[i].mixModeStr = mixModeStr || 'source-over';
                BB.ctx(this.layerCanvasArr[i]).drawImage(inLayers[i].image, 0, 0);
            }
        }
        this.updateIndices();
    }

    setHistory (h: KlHistoryInterface): void {
        this.history = h;
    }

    /**
     * Resets canvas -> 1 layer, 100% opacity,
     * unless layers provided.
     * @param p
     */
    reset (
        p: {
            width: number;
            height: number;
            color?: IRGB; // optional - fill color
            image?: HTMLImageElement | HTMLCanvasElement; // image drawn on layer
            layerName?: string; // if via image
            layers?: {
                name: string;
                isVisible: boolean;
                opacity: number;
                mixModeStr: TMixMode;
                image: HTMLCanvasElement;
            }[];
        }
    ): number {
        if (!p.width || !p.height || p.width < 1 || p.height < 1 || isNaN(p.width) || isNaN(p.height) ) {
            throw new Error('invalid canvas size');
        }

        this.history.pause(true);

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

        this.layerCanvasArr.splice(1, Math.max(0, this.layerCanvasArr.length - 1));

        if (p.layers) {
            for (let i = 0; i < p.layers.length; i++) {
                const item = p.layers[i];
                if (!this.layerCanvasArr[i]) {
                    this.addLayer();
                }
                this.layerCanvasArr[i].name = item.name;
                this.layerCanvasArr[i].isVisible = item.isVisible;
                this.layerCanvasArr[i].width = this.width;
                this.layerCanvasArr[i].height = this.height;
                this.layerCanvasArr[i].mixModeStr = item.mixModeStr ? item.mixModeStr : 'source-over';
                BB.ctx(this.layerCanvasArr[i]).drawImage(item.image, 0, 0);
                this.layerOpacity(i, item.opacity);
            }
        } else {
            this.layerCanvasArr[0].name = p.layerName ? p.layerName : LANG('layers-layer') + ' 1';
            this.layerCanvasArr[0].isVisible = true;
            this.layerCanvasArr[0].width = this.width;
            this.layerCanvasArr[0].height = this.height;
            this.layerCanvasArr[0].mixModeStr = 'source-over';
            this.layerOpacity(0, 1);
            if (p.color) {
                this.layerFill(0, p.color);
            } else if (p.image) {
                BB.ctx(this.layerCanvasArr[0]).drawImage(p.image, 0, 0);
            }
        }
        this.updateIndices();

        this.history.pause(false);

        this.history.push({
            tool: ['canvas'],
            action: 'reset',
            params: [p], // don't modify with p
        } as TKlCanvasHistoryEntry);

        return this.layerCanvasArr.length - 1;
    }

    isLayerLimitReached (): boolean {
        return this.layerCanvasArr.length >= MAX_LAYERS;
    }

    getWidth (): number {
        return this.width;
    }

    getHeight (): number {
        return this.height;
    }

    copy (toCopyCanvas: KlCanvas): void {
        if (
            toCopyCanvas.getWidth() < 1 ||
            toCopyCanvas.getHeight() < 1 ||
            isNaN(toCopyCanvas.getWidth()) ||
            isNaN(toCopyCanvas.getHeight())
        ) {
            throw new Error('invalid canvas size');
        }

        // keep existing canvases

        const origLayers = toCopyCanvas.getLayers();

        while (this.layerCanvasArr.length > origLayers.length) {
            this.removeLayer(this.layerCanvasArr.length - 1);
        }

        if (toCopyCanvas.getWidth() != this.width || toCopyCanvas.getHeight() != this.height) {
            this.init(toCopyCanvas.getWidth(), toCopyCanvas.getHeight());
        }
        for (let i = 0; i < origLayers.length; i++) {
            if (i >= this.layerCanvasArr.length) {
                this.addLayer();
            } else {
                this.layerCanvasArr[i].width = this.width;
                this.layerCanvasArr[i].height = this.height;
            }
            this.layerOpacity(i, origLayers[i].opacity);
            this.layerCanvasArr[i].name = origLayers[i].name;
            this.layerCanvasArr[i].isVisible = origLayers[i].isVisible;
            this.layerCanvasArr[i].mixModeStr = origLayers[i].mixModeStr;
            BB.ctx(this.layerCanvasArr[i]).drawImage(origLayers[i].context.canvas, 0, 0);
        }
        this.updateIndices();
    }

    getLayerCount (): number {
        return this.layerCanvasArr.length;
    }

    resize (w: number, h: number, algorithm: 'smooth' | 'pixelated' = 'smooth'): boolean {
        if (!w || !h || (w === this.width && h === this.height) || isNaN(w) || isNaN(h) || w < 1 || h < 1) {
            return false;
        }
        w = Math.max(w, 1);
        h = Math.max(h, 1);

        let tmp1, tmp2;

        if (algorithm === 'pixelated') {
            tmp1 = BB.canvas(w, h);
            const tmp1Ctx = BB.ctx(tmp1);
            tmp1Ctx.imageSmoothingEnabled = false;
            for (let i = 0; i < this.layerCanvasArr.length; i++) {
                if (i > 0) {
                    tmp1Ctx.clearRect(0, 0, w, h);
                }
                const layerCanvas = this.layerCanvasArr[i];
                tmp1Ctx.drawImage(layerCanvas, 0, 0, w, h);
                layerCanvas.width = w;
                layerCanvas.height = h;
                const layerContext = BB.ctx(layerCanvas);
                layerContext.drawImage(tmp1, 0, 0);
            }

        } else if (algorithm === 'smooth') {
            tmp1 = BB.canvas();
            tmp2 = BB.canvas();
            for (let i = 0; i < this.layerCanvasArr.length; i++) {
                BB.resizeCanvas(this.layerCanvasArr[i], w, h, tmp1, tmp2);
            }
        } else {
            throw new Error('unknown resize algorithm');
        }

        this.width = w;
        this.height = h;
        return true;
    }

    /**
     * crop / extend
     * @param p
     */
    resizeCanvas (p: {
        left: number;
        top: number;
        right: number;
        bottom: number;
        fillColor?: IRGB;
    }): void {

        const newW = Math.round(p.left) + this.width + Math.round(p.right);
        const newH = Math.round(p.top) + this.height + Math.round(p.bottom);
        const offX = Math.round(p.left);
        const offY = Math.round(p.top);

        if (isNaN(newW) || isNaN(newH) || newW < 1 || newH < 1) {
            throw new Error('KlCanvas.resizeCanvas - invalid canvas size');
        }

        for (let i = 0; i < this.layerCanvasArr.length; i++) {
            const ctemp = BB.canvas(this.width, this.height);
            const layerCanvas = this.layerCanvasArr[i];
            const layerCtx = BB.ctx(this.layerCanvasArr[i]);
            BB.ctx(ctemp).drawImage(layerCanvas, 0, 0);

            this.layerCanvasArr[i].width = newW;
            this.layerCanvasArr[i].height = newH;

            layerCtx.save();
            if (i === 0 && p.fillColor) {
                layerCtx.fillStyle = BB.ColorConverter.toRgbStr(p.fillColor);
                layerCtx.fillRect(0, 0, newW, newH);
                layerCtx.clearRect(offX, offY, this.width, this.height);
            }
            layerCtx.drawImage(ctemp, offX, offY);
            layerCtx.restore();
        }
        this.width = newW;
        this.height = newH;
    }

    /**
     * will be inserted on top of selected
     * @param selected
     */
    addLayer (selected?: number): false | number {
        if (this.isLayerLimitReached()) {
            return false;
        }
        const canvas = BB.canvas(this.width, this.height) as KlCanvasLayer;
        if (!canvas.getContext('2d')) {
            throw new Error('kl-create-canvas-error');
        }

        canvas.isVisible = true;
        canvas.mixModeStr = 'source-over';

        if (selected === undefined) {
            this.layerCanvasArr[this.layerCanvasArr.length] = canvas;
            selected = Math.max(0, this.layerCanvasArr.length - 1);
        } else {
            this.layerCanvasArr.splice(selected + 1, 0, canvas);
            selected++;
        }

        canvas.name = LANG('layers-layer') + ' ' + (this.layerCanvasArr.length + this.layerNrOffset);
        this.history.pause(true);
        this.layerOpacity(selected, 1);
        this.history.pause(false);
        this.updateIndices();
        this.history.push({
            tool: ['canvas'],
            action: 'addLayer',
            params: [selected - 1],
        } as TKlCanvasHistoryEntry);
        return selected;
    }

    duplicateLayer (i: number): false | number {
        if (!this.layerCanvasArr[i] || this.isLayerLimitReached()) {
            return false;
        }
        const canvas = BB.canvas(this.width, this.height) as KlCanvasLayer;
        this.layerCanvasArr.splice(i + 1, 0, canvas);

        canvas.name = this.layerCanvasArr[i].name + ' ' + LANG('layers-copy');
        canvas.isVisible = this.layerCanvasArr[i].isVisible;
        canvas.mixModeStr = this.layerCanvasArr[i].mixModeStr;
        // 2023-04-30 workaround for https://bugs.webkit.org/show_bug.cgi?id=256151
        // todo replace with simple drawImage eventually when fixed
        BB.ctx(canvas).putImageData(BB.ctx(this.layerCanvasArr[i]).getImageData(0, 0, this.width, this.height), 0, 0);
        this.history.pause(true);
        this.layerOpacity(i + 1, this.layerCanvasArr[i].opacity);
        this.history.pause(false);

        this.updateIndices();

        this.history.push({
            tool: ['canvas'],
            action: 'duplicateLayer',
            params: [i],
        } as TKlCanvasHistoryEntry);
        return i + 1;
    }

    getLayerContext (i: number, doReturnNull?: boolean): CanvasRenderingContext2D | null {
        if (this.layerCanvasArr[i]) {
            return BB.ctx(this.layerCanvasArr[i]);
        }
        if (doReturnNull) {
            return null;
        }
        throw new Error('layer of index ' + i + ' not found (in ' + this.layerCanvasArr.length + ' layers)');
    }

    removeLayer (i: number): false | number {
        if (this.layerCanvasArr[i]) {
            this.layerCanvasArr.splice(i, 1);
            this.updateIndices();
        } else {
            return false;
        }
        this.history.push({
            tool: ['canvas'],
            action: 'removeLayer',
            params: [i],
        } as TKlCanvasHistoryEntry);

        return Math.max(0, i - 1);
    }

    renameLayer (i: number, name: string): boolean {
        if (this.layerCanvasArr[i]) {
            this.layerCanvasArr[i].name = name;
        } else {
            return false;
        }

        this.history.push({
            tool: ['canvas'],
            action: 'renameLayer',
            params: [i, name],
        } as TKlCanvasHistoryEntry);

        return true;
    }

    layerOpacity (layerIndex: number, opacity: number): void {
        if (!this.layerCanvasArr[layerIndex]) {
            return;
        }
        opacity = Math.max(0, Math.min(1, opacity));
        this.layerCanvasArr[layerIndex].opacity = opacity;

        this.history.push({
            tool: ['canvas'],
            action: 'layerOpacity',
            params: [layerIndex, opacity],
        } as TKlCanvasHistoryEntry);

        this.emitChange();
    }

    setLayerIsVisible (layerIndex: number, isVisible: boolean): void {
        if (this.layerCanvasArr[layerIndex]) {
            this.layerCanvasArr[layerIndex].isVisible = isVisible;
        } else {
            throw new Error(`layer ${layerIndex} undefined`);
        }

        this.history.push({
            tool: ['canvas'],
            action: 'setLayerIsVisible',
            params: [layerIndex, isVisible],
        } as TKlCanvasHistoryEntry);
    }

    moveLayer (i: number, d: number): void | number {
        if (d === 0) {
            return;
        }
        if (this.layerCanvasArr[i]) {
            const temp = this.layerCanvasArr[i];
            this.layerCanvasArr.splice(i, 1);
            const targetIndex = Math.max(0, Math.min(i + d, this.layerCanvasArr.length));
            this.layerCanvasArr.splice(targetIndex, 0, temp);
            this.updateIndices();
            this.history.push({
                tool: ['canvas'],
                action: 'moveLayer',
                params: [i, d],
            } as TKlCanvasHistoryEntry);
            return targetIndex;
        }
    }

    mergeLayers (layerBottomIndex: number, layerTopIndex: number, mixModeStr: TMixMode | 'as-alpha'): void | number {
        if (
            !this.layerCanvasArr[layerBottomIndex] ||
            !this.layerCanvasArr[layerTopIndex] ||
            layerBottomIndex === layerTopIndex
        ) {
            return;
        }
        //order messed up
        if (layerBottomIndex > layerTopIndex) {
            const temp = layerBottomIndex;
            layerBottomIndex = layerTopIndex;
            layerTopIndex = temp;
        }

        const topOpacity = this.layerCanvasArr[layerTopIndex].opacity;
        if (topOpacity !== 0 && topOpacity) {
            const ctx = BB.ctx(this.layerCanvasArr[layerBottomIndex]);
            ctx.save();

            if (mixModeStr === 'as-alpha') { // todo remove this?

                BB.convertToAlphaChannelCanvas(this.layerCanvasArr[layerTopIndex]);
                ctx.globalCompositeOperation = 'destination-in';
                ctx.globalAlpha = topOpacity;
                BB.ctx(this.layerCanvasArr[layerBottomIndex]).drawImage(this.layerCanvasArr[layerTopIndex], 0, 0);

            } else {

                if (mixModeStr) {
                    ctx.globalCompositeOperation = mixModeStr;
                }
                ctx.globalAlpha = topOpacity;
                BB.ctx(this.layerCanvasArr[layerBottomIndex]).drawImage(this.layerCanvasArr[layerTopIndex], 0, 0);

            }

            ctx.restore();

            // workaround for chrome bug https://bugs.chromium.org/p/chromium/issues/detail?id=1281185
            // TODO remove if chrome updated
            if (mixModeStr) {
                ctx.save();
                ctx.fillStyle = 'rgba(0,0,0,0.01)';
                ctx.fillRect(-0.9999999, -0.9999999, 1, 1);
                ctx.restore();
            }
        }
        this.updateIndices();
        this.history.pause(true);
        this.removeLayer(layerTopIndex);
        this.history.pause(false);
        this.history.push({
            tool: ['canvas'],
            action: 'mergeLayers',
            params: [layerBottomIndex, layerTopIndex, mixModeStr],
        } as TKlCanvasHistoryEntry);

        return layerBottomIndex;
    }

    rotate (deg: number): void {
        while (deg < 0) {
            deg += 360;
        }
        deg %= 360;
        if (deg % 90 != 0 || deg === 0) {
            return;
        }
        const temp = BB.canvas();
        if (deg === 0 || deg === 180) {
            temp.width = this.width;
            temp.height = this.height;
        } else if (deg === 90 || deg === 270) {
            temp.width = (this.height);
            temp.height = (this.width);
        }
        const ctx = BB.ctx(temp);
        for (let i = 0; i < this.layerCanvasArr.length; i++) {
            ctx.clearRect(0, 0, temp.width, temp.height);
            ctx.save();
            ctx.translate(temp.width / 2, temp.height / 2);
            ctx.rotate(deg * Math.PI / 180);
            if (deg === 180) {
                ctx.drawImage(this.layerCanvasArr[i], -temp.width / 2, -temp.height / 2);
            } else if (deg === 90 || deg === 270) {
                ctx.drawImage(this.layerCanvasArr[i], -temp.height / 2, -temp.width / 2);
            }
            this.layerCanvasArr[i].width = temp.width;
            this.layerCanvasArr[i].height = temp.height;
            BB.ctx(this.layerCanvasArr[i]).clearRect(0, 0, this.layerCanvasArr[i].width, this.layerCanvasArr[i].height);
            BB.ctx(this.layerCanvasArr[i]).drawImage(temp, 0, 0);
            ctx.restore();
        }
        this.width = temp.width;
        this.height = temp.height;
    }

    flip (isHorizontal: boolean, isVertical: boolean, layerIndex?: number): void {
        if (!isHorizontal && !isVertical) {
            return;
        }

        const temp = BB.canvas(this.width, this.height);
        temp.width = this.width;
        temp.height = this.height;
        const tempCtx = BB.ctx(temp);

        for (let i = 0; i < this.layerCanvasArr.length; i++) {

            if ( (layerIndex || layerIndex === 0) && i !== layerIndex) {
                continue;
            }

            tempCtx.save();
            tempCtx.clearRect(0, 0, temp.width, temp.height);
            tempCtx.translate(temp.width / 2, temp.height / 2);
            tempCtx.scale((isHorizontal ? -1 : 1), (isVertical ? -1 : 1));
            tempCtx.drawImage(this.layerCanvasArr[i], -temp.width / 2, -temp.height / 2);
            tempCtx.restore();

            BB.ctx(this.layerCanvasArr[i]).clearRect(0, 0, this.layerCanvasArr[i].width, this.layerCanvasArr[i].height);
            BB.ctx(this.layerCanvasArr[i]).drawImage(temp, 0, 0);
        }
    }

    layerFill (layerIndex: number, colorObj: IRGB, compositeOperation?: string): void {
        const ctx = BB.ctx(this.layerCanvasArr[layerIndex]);
        ctx.save();
        if (compositeOperation) {
            ctx.globalCompositeOperation = compositeOperation as GlobalCompositeOperation;
        }
        ctx.fillStyle = 'rgba(' + colorObj.r + ',' + colorObj.g + ',' + colorObj.b + ',1)';
        ctx.fillRect(0, 0, this.layerCanvasArr[layerIndex].width, this.layerCanvasArr[layerIndex].height);
        ctx.restore();

        // workaround for chrome bug https://bugs.chromium.org/p/chromium/issues/detail?id=1281185
        // TODO remove if chrome updated
        ctx.save();
        ctx.fillStyle = 'rgba(0,0,0,0.01)';
        ctx.fillRect(-0.9999999, -0.9999999, 1, 1);
        ctx.restore();

        /*if (!document.getElementById('testocanvas')) {
            layerCanvasArr[layerIndex].id = 'testocanvas';
            document.body.append(layerCanvasArr[layerIndex]);
            BB.css(layerCanvasArr[layerIndex], {
                position: 'fixed',
                left: '0',
                top: '0',
                zIndex: '1111111',
                transform: 'scale(0.2)',
                border: '10px solid red',
            });
        }
        if (!document.getElementById('testocanvas')) {
            let c = document.createElement('canvas');
            c.width = 1000;
            c.height = 1000;
            let ctx2 = c.getContext('2d');
            ctx2.drawImage(layerCanvasArr[layerIndex], 0, 0);
            c.id = 'testocanvas';
            document.body.append(c);
            BB.css(c, {
                position: 'fixed',
                left: '0',
                top: '0',
                zIndex: '1111111',
                transform: 'scale(0.2)',
                border: '10px solid red',
            });
        }*/

        this.history.push({
            tool: ['canvas'],
            action: 'layerFill',
            params: [layerIndex, colorObj, compositeOperation],
        } as TKlCanvasHistoryEntry);
    }

    floodFill (
        layerIndex: number, // index of layer to be filled
        x: number, // starting point
        y: number,
        rgb: IRGB | null, // fill color, if null -> erase
        opacity: number,
        tolerance: number,
        sampleStr: TFillSampling,
        grow: number, // int >= 0 - radius around filled area that is to be filled too
        isContiguous: boolean,
    ): void {
        if (x < 0 || y < 0 || x >= this.width || y >= this.height || opacity === 0) {
            return;
        }
        tolerance = Math.round(tolerance);

        if (!(['above', 'current', 'all'].includes(sampleStr))) {
            throw new Error('invalid sampleStr');
        }

        let result;
        let targetCtx;
        let targetImageData;

        if (sampleStr === 'all') {

            const srcCanvas = this.layerCanvasArr.length === 1 ? this.layerCanvasArr[0] : this.getCompleteCanvas(1);
            const srcCtx = BB.ctx(srcCanvas);
            const srcImageData = srcCtx.getImageData(0, 0, this.width, this.height);
            const srcData = srcImageData.data;
            result = floodFillBits(srcData, this.width, this.height, x, y, tolerance, Math.round(grow), isContiguous);

            targetCtx = BB.ctx(this.layerCanvasArr[layerIndex]);
            targetImageData = targetCtx.getImageData(0, 0, this.width, this.height);

        } else {
            const srcIndex = sampleStr === 'above' ? layerIndex + 1 : layerIndex;

            if (srcIndex >= this.layerCanvasArr.length) {
                return;
            }

            const srcCtx = BB.ctx(this.layerCanvasArr[srcIndex]);
            const srcImageData = srcCtx.getImageData(0, 0, this.width, this.height);
            const srcData = srcImageData.data;
            result = floodFillBits(srcData, this.width, this.height, x, y, tolerance, Math.round(grow), isContiguous);

            targetCtx = layerIndex === srcIndex ? srcCtx : BB.ctx(this.layerCanvasArr[layerIndex]);
            targetImageData = layerIndex === srcIndex ? srcImageData : targetCtx.getImageData(0, 0, this.width, this.height);

        }

        const targetData = targetImageData.data;
        if (rgb) {
            if (opacity === 1) {
                for (let i = 0; i < this.width * this.height; i++) {
                    if (result.data[i] === 255) {
                        targetData[i * 4] = rgb.r;
                        targetData[i * 4 + 1] = rgb.g;
                        targetData[i * 4 + 2] = rgb.b;
                        targetData[i * 4 + 3] = 255;
                    }
                }
            } else {
                for (let i = 0; i < this.width * this.height; i++) {
                    if (result.data[i] === 255) {
                        targetData[i * 4] = BB.mix(targetData[i * 4], rgb.r, opacity);
                        targetData[i * 4 + 1] = BB.mix(targetData[i * 4 + 1], rgb.g, opacity);
                        targetData[i * 4 + 2] = BB.mix(targetData[i * 4 + 2], rgb.b, opacity);
                        targetData[i * 4 + 3] = BB.mix(targetData[i * 4 + 3], 255, opacity);
                    }
                }
            }
        } else { // erase
            if (opacity === 1) {
                for (let i = 0; i < this.width * this.height; i++) {
                    if (result.data[i] === 255) {
                        targetData[i * 4 + 3] = 0;
                    }
                }
            } else {
                for (let i = 0; i < this.width * this.height; i++) {
                    if (result.data[i] === 255) {
                        targetData[i * 4 + 3] = BB.mix(targetData[i * 4 + 3], 0, opacity);
                    }
                }
            }
        }
        targetCtx.putImageData(targetImageData, 0, 0);

        this.history.push({
            tool: ['canvas'],
            action: 'replaceLayer',
            params: [layerIndex, targetImageData],
        } as TKlCanvasHistoryEntry);
    }

    /**
     * draw shape via BB.drawShape
     * @param layerIndex
     * @param shapeObj
     */
    drawShape (layerIndex: number, shapeObj: IShapeToolObject): void {
        if (shapeObj.x1 === shapeObj.x2 && shapeObj.y1 === shapeObj.y2) {
            return;
        }
        drawShape(BB.ctx(this.layerCanvasArr[layerIndex]), shapeObj);
        this.history.push({
            tool: ['canvas'],
            action: 'drawShape',
            params: [layerIndex, BB.copyObj(shapeObj)],
        } as TKlCanvasHistoryEntry);
    }

    drawGradient (layerIndex: number, gradientObj: IGradient): void {
        drawGradient(BB.ctx(this.layerCanvasArr[layerIndex]), gradientObj);
        this.history.push({
            tool: ['canvas'],
            action: 'drawGradient',
            params: [layerIndex, BB.copyObj(gradientObj)],
        } as TKlCanvasHistoryEntry);
    }

    text (layerIndex: number, p: TRenderTextParam): void {
        renderText(this.layerCanvasArr[layerIndex], BB.copyObj(p));
        this.history.push({
            tool: ['canvas'],
            action: 'text',
            params: [layerIndex, BB.copyObj(p)],
        } as TKlCanvasHistoryEntry);
    }

    replaceLayer (layerIndex: number, imageData: ImageData): void {
        const ctx = BB.ctx(this.layerCanvasArr[layerIndex]);
        ctx.putImageData(imageData, 0, 0);
        this.history.push({
            tool: ['canvas'],
            action: 'replaceLayer',
            params: [layerIndex, imageData],
        } as TKlCanvasHistoryEntry);
    }

    clearLayer (layerIndex: number): void {
        const ctx = BB.ctx(this.layerCanvasArr[layerIndex]);
        ctx.save();
        ctx.clearRect(0, 0, this.layerCanvasArr[layerIndex].width, this.layerCanvasArr[layerIndex].height);
        ctx.restore();

        this.history.push({
            tool: ['canvas'],
            action: 'clearLayer',
            params: [layerIndex],
        } as TKlCanvasHistoryEntry);
    }

    getLayers (): {
        context: CanvasRenderingContext2D;
        isVisible: boolean;
        opacity: number;
        name: string;
        mixModeStr: TMixMode;
    }[] {
        return this.layerCanvasArr.map(item => {
            return {
                context: BB.ctx(item),
                isVisible: item.isVisible,
                opacity: item.opacity,
                name: item.name,
                mixModeStr: item.mixModeStr,
            };
        });
    }

    getLayersFast (): {
        canvas: KlCanvasLayer;
        isVisible: boolean;
        opacity: number;
        name: string;
        mixModeStr: TMixMode;
    }[] {
        return this.layerCanvasArr.map(item => {
            return {
                canvas: item,
                isVisible: item.isVisible,
                opacity: item.opacity,
                name: item.name,
                mixModeStr: item.mixModeStr,
            };
        });
    }

    getLayerIndex (canvasObj: HTMLCanvasElement, doReturnNull?: boolean): null | number {
        for (let i = 0; i < this.layerCanvasArr.length; i++) {
            if (this.layerCanvasArr[i] === canvasObj) {
                return i;
            }
        }
        if (!doReturnNull) {
            throw new Error('layer not found (in ' + this.layerCanvasArr.length + ' layers)');
        }
        return null;
    }

    getLayer (index: number, doReturnNull?: boolean): null | TKlCanvasLayer {
        if (this.layerCanvasArr[index]) {
            return {
                context: BB.ctx(this.layerCanvasArr[index]),
                isVisible: this.layerCanvasArr[index].isVisible,
                opacity: this.layerCanvasArr[index].opacity,
                name: this.layerCanvasArr[index].name,
                id: index,
            };
        }
        if (!doReturnNull) {
            throw new Error('layer of index ' + index + ' not found (in ' + this.layerCanvasArr.length + ' layers)');
        }
        return null;
    }

    getColorAt (x: number, y: number): IRGB {
        x = Math.floor(x);
        y = Math.floor(y);
        const ctx = BB.ctx(this.pickCanvas);
        ctx.save();
        ctx.imageSmoothingEnabled = false;
        ctx.clearRect(0, 0, 1, 1);
        for (let i = 0; i < this.layerCanvasArr.length; i++) {
            const layer = this.layerCanvasArr[i];
            if (!layer.isVisible || layer.opacity === 0) {
                continue;
            }
            ctx.globalAlpha = layer.opacity;
            ctx.globalCompositeOperation = layer.mixModeStr;
            ctx.drawImage(layer, -x, -y);
        }
        ctx.restore();
        const imData = ctx.getImageData(0, 0, 1, 1);
        return new BB.RGB(imData.data[0], imData.data[1], imData.data[2]);
    }

    getCompleteCanvas (factor: number): HTMLCanvasElement {
        return drawProject(this.getProject(), factor);
    }

    getProject (): IKlProject {
        return {
            width: this.width,
            height: this.height,
            layers: this.layerCanvasArr.map(layer => {
                return {
                    name: layer.name,
                    isVisible: layer.isVisible,
                    opacity: layer.opacity,
                    mixModeStr: layer.mixModeStr,
                    image: layer,
                };
            }),
        };
    }

    addChangeListener (func: () => void): void {
        if (this.changeListenerArr.includes(func)) {
            return;
        }
        this.changeListenerArr.push(func);
    }

    removeChangeListener (func: () => void): void {
        for (let i = 0; i < this.changeListenerArr.length; i++) {
            if (this.changeListenerArr[i] === func) {
                this.changeListenerArr.splice(i, 1);
                return;
            }
        }
    }

    setMixMode (layerIndex: number, mixModeStr: TMixMode): void {
        if (!this.layerCanvasArr[layerIndex]) {
            throw new Error('invalid layer');
        }
        this.layerCanvasArr[layerIndex].mixModeStr = mixModeStr;

        this.history.push({
            tool: ['canvas'],
            action: 'setMixMode',
            params: [layerIndex, '' + mixModeStr],
        } as TKlCanvasHistoryEntry);
    }

    /**
     * Set composite drawing step for KlCanvasWorkspace.
     * To apply temporary manipulations to a layer.
     *
     * @param layerIndex
     * @param compositeObj
     */
    setComposite (
        layerIndex: number,
        compositeObj: null | {
            draw: (ctx: CanvasRenderingContext2D) => void;
        }
    ): void {
        if (!this.layerCanvasArr[layerIndex]) {
            throw new Error('invalid layer');
        }
        this.layerCanvasArr[layerIndex].compositeObj = nullToUndefined(compositeObj);
    }

    destroy (): void {
        if (this.isDestroyed) {
            return;
        }
        this.layerCanvasArr.forEach(canvas => {
            BB.freeCanvas(canvas);
        });
        this.layerCanvasArr = [];
        this.isDestroyed = true;
    }

}


code:
import {TMixMode} from '../kl-types';
import {LANG} from '../../language/language';
import {TTranslationCode} from '../../../languages/languages';

export function translateBlending (blendMode?: TMixMode): string {
    if (!blendMode) {
        return LANG('layers-blend-normal');
    }

    const codes: {
        [key: string]: TTranslationCode;
    } = {
        'source-over': 'layers-blend-normal',
        'darken': 'layers-blend-darken',
        'multiply': 'layers-blend-multiply',
        'color-burn': 'layers-blend-color-burn',
        'lighten': 'layers-blend-lighten',
        'screen': 'layers-blend-screen',
        'color-dodge': 'layers-blend-color-dodge',
        'overlay': 'layers-blend-overlay',
        'soft-light': 'layers-blend-soft-light',
        'hard-light': 'layers-blend-hard-light',
        'difference': 'layers-blend-difference',
        'exclusion': 'layers-blend-exclusion',
        'hue': 'layers-blend-hue',
        'saturation': 'layers-blend-saturation',
        'color': 'layers-blend-color',
        'luminosity': 'layers-blend-luminosity',
    };

    if (!(blendMode in codes)) {
        throw new Error('unknown blend mode');
    }
    return LANG(codes[blendMode]);
}

repo_name: Doggermelon/klecks

license: mit


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

/**
 * cleans up DrawEvents. More trustworthy events. EventChain element
 *
 * in some draw event?
 * out some draw event?
 *
 * that events can only go line this: down -> n x move -> up
 * so, sanitizes this: down, down, down. becomes only one down. the other downs are ignored/swallowed
 */
export class LineSanitizer {

    private chainOut: ((drawEvent: TDrawEvent) => void) | undefined;
    private isDrawing: boolean = false;

    // --- public ---

    chainIn (event: TDrawEvent): TDrawEvent | null {

        if (event.type === 'down') {
            if (this.isDrawing) {
                //console.log('line sanitizer - down, but already drawing');
                this.chainOut && this.chainOut({
                    type: 'up',
                    scale: event.scale,
                    shiftIsPressed: event.shiftIsPressed,
                    isCoalesced: false,
                });
            } else {
                this.isDrawing = true;
            }
        }
        if (!this.isDrawing && (event.type === 'move' || event.type === 'up')) {
            //console.log('line sanitizer - ' + event.type + ' but not drawing');
            return null;
        }

        if (event.type === 'up' && this.isDrawing) {
            this.isDrawing = false;
        }

        return event;
    }

    setChainOut (func: (drawEvent: TDrawEvent) => void): void {
        this.chainOut = func;
    }

    getIsDrawing (): boolean {
        return this.isDrawing;
    }
}


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

/**
 * Line smoothing. EventChain element. Smoothing via blending new position with old position.
 * for onDraw events from KlCanvasWorkspace.
 *
 * in some draw event
 * out some draw event
 *
 * type: 'line' Events are just passed through.
 */
export class LineSmoothing {

    private chainOut: ((drawEvent: TDrawEvent) => void) | undefined;
    private smoothing: number;
    private lastMixedInput: {
        x: number;
        y: number;
        pressure: number;
    } | undefined;
    private interval: ReturnType<typeof setInterval> | undefined;
    private timeout: ReturnType<typeof setTimeout> | undefined;

    // --- public ---
    constructor (
        p: {
            smoothing: number; // 0-1, 0: no smoothing, 1: 100% smoothing -> would never catch up
        }
    ) {
        this.smoothing = BB.clamp(p.smoothing, 0, 1);
    }

    chainIn (event: TDrawEvent): TDrawEvent | null {
        event = BB.copyObj(event);
        this.timeout && clearTimeout(this.timeout);
        this.interval && clearInterval(this.interval);

        if (event.type === 'down') {
            this.lastMixedInput = {
                x: event.x,
                y: event.y,
                pressure: event.pressure,
            };
        }

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

            const inputX = event.x;
            const inputY = event.y;
            const inputPressure = event.pressure;

            event.x = BB.mix(event.x, this.lastMixedInput!.x, this.smoothing);
            event.y = BB.mix(event.y, this.lastMixedInput!.y, this.smoothing);
            event.pressure = BB.mix(event.pressure, this.lastMixedInput!.pressure, this.smoothing);
            this.lastMixedInput = {
                x: event.x,
                y: event.y,
                pressure: event.pressure,
            };

            if (this.smoothing > 0) {
                this.timeout = setTimeout(() => {
                    this.interval = setInterval(() => {
                        event = JSON.parse(JSON.stringify(event)) as IDrawMoveEvent;

                        event.x = BB.mix(inputX, this.lastMixedInput!.x, this.smoothing);
                        event.y = BB.mix(inputY, this.lastMixedInput!.y, this.smoothing);
                        event.pressure = BB.mix(inputPressure, this.lastMixedInput!.pressure, this.smoothing);
                        this.lastMixedInput = {
                            x: event.x,
                            y: event.y,
                            pressure: event.pressure,
                        };

                        this.chainOut && this.chainOut(event);
                    }, 35);
                }, 80);
            }

        }

        return event;
    }

    setChainOut (func: (drawEvent: TDrawEvent) => void): void {
        this.chainOut = func;
    }

    setSmoothing (s: number): void {
        this.smoothing = BB.clamp(s, 0, 1);
    }
}


code:
import {IDrawDownEvent, IDrawMoveEvent, IDrawUpEvent, TDrawEvent} from '../kl-types';
import {BB} from '../../bb/bb';

const DIR_X = 0;
const DIR_Y = 1;

/**
 * Not really and event chain element. but pretty similar.
 *
 * Processes draw input events. When shift held -> linetool
 * line events - what KlCanvasWorkspace passes onDraw(e)
 *
 * pass input in with process(drawEvent)
 *
 * modifies draw event to include such events:
 * {
 *     type: 'line',
 *     x0: null, // you have to fill that in
 *     y0: null, // you have to fill that in
 *     pressure0: null, // you have to fill that in
 *     x1: number,
 *     y1: number,
 *     pressure1: number
 * }
 */
export class LinetoolProcessor {

    private readonly onDraw: (drawEvent: TDrawEvent) => void;
    private downEvent: IDrawDownEvent | null = null;
    private eventQueue: (IDrawDownEvent | IDrawMoveEvent | IDrawUpEvent)[] = [];
    private direction: 0 | 1 | null = null;

    // --- public ---
    constructor (
        p : {
            onDraw: (drawEvent: TDrawEvent) => void;
        }
    ) {
        this.onDraw = p.onDraw;
    }

    process (event: IDrawDownEvent | IDrawMoveEvent | IDrawUpEvent): void {
        if (event.type === 'down') {
            this.downEvent = event;
            this.direction = null;

            if (event.shiftIsPressed) {

                this.onDraw({
                    type: 'line',
                    x0: null,
                    y0: null,
                    pressure0: null,
                    x1: event.x,
                    y1: event.y,
                    pressure1: event.pressure,
                });

                this.eventQueue.push(event);
                return;
            }

        }

        if (event.type === 'move' && this.downEvent) {

            if (event.shiftIsPressed) {

                if (this.direction === null) {
                    const dX = Math.abs(event.x - this.downEvent.x);
                    const dY = Math.abs(event.y - this.downEvent.y);

                    if (dX > 5 || dY > 5) {
                        this.direction = dX > dY ? DIR_X : DIR_Y;

                        for (let i = 0; i < this.eventQueue.length; i++) {
                            const e = this.eventQueue[i];
                            if (e.type !== 'up') {
                                if (this.direction === DIR_X) {
                                    e.y = this.downEvent.y;
                                } else {
                                    e.x = this.downEvent.x;
                                }
                            }
                            this.onDraw(BB.copyObj(e));
                        }
                        this.eventQueue = [];
                    }
                }

                if (this.direction === null) {
                    this.eventQueue.push(event);
                    return;
                }

                if (this.direction === DIR_X) {
                    event.y = this.downEvent.y;
                } else {
                    event.x = this.downEvent.x;
                }

            } else {
                if (this.eventQueue.length > 0) {
                    for (let i = 0; i < this.eventQueue.length; i++) {
                        this.onDraw(BB.copyObj(this.eventQueue[i]));
                    }
                    this.eventQueue = [];
                }
            }

        }

        if (event.type === 'up') {
            this.eventQueue = [];
            this.downEvent = null;
        }

        this.onDraw(BB.copyObj(event));
    }
}


code:
import {BB} from '../../bb/bb';
import {KlSlider} from '../ui/components/kl-slider';
import {eventResMs} from './filters-consts';
import {KlCanvasPreview} from '../canvas-ui/canvas-preview';
import {getSharedFx} from '../../fx-canvas/shared-fx';
import {IFilterApply, IFilterGetDialogParam, IFilterGetDialogResult, IKlBasicLayer} from '../kl-types';
import {LANG} from '../../language/language';
import {TFilterHistoryEntry} from './filters';

export type TFilterBlurInput = {
    radius: number;
}

export type TFilterBlurHistoryEntry = TFilterHistoryEntry<
    'blur',
    TFilterBlurInput>;

export const filterBlur = {

    getDialog (params: IFilterGetDialogParam) {
        const klCanvas = params.klCanvas;
        const context = params.context;
        if (!klCanvas || !context) {
            return false;
        }

        const layers = klCanvas.getLayers();
        const selectedLayerIndex = klCanvas.getLayerIndex(context.canvas);

        const fit = BB.fitInto(context.canvas.width, context.canvas.height, 280, 200, 1);
        const displayW = parseInt('' + fit.width), displayH = parseInt('' + fit.height);
        const w = Math.min(displayW, context.canvas.width);
        const h = Math.min(displayH, context.canvas.height);

        const tempCanvas = BB.canvas(w, h);
        {
            const ctx = BB.ctx(tempCanvas);
            ctx.save();
            if (w > context.canvas.width) {
                ctx.imageSmoothingEnabled = false;
            }
            ctx.drawImage(context.canvas, 0, 0, w, h);
            ctx.restore();
        }
        const previewFactor = w / context.canvas.width;

        const div = document.createElement('div');
        const result: IFilterGetDialogResult<TFilterBlurInput> = {
            element: div,
        };

        function finishInit (): void {
            let radius = 10;
            div.innerHTML = LANG('filter-triangle-blur-description') + '

';

            const fxCanvas = getSharedFx();
            if (!fxCanvas) {
                return; // todo throw?
            }
            const texture = fxCanvas.texture(tempCanvas);
            fxCanvas.draw(texture).update(); // update fxCanvas size

            const radiusSlider = new KlSlider({
                label: LANG('radius'),
                width: 300,
                height: 30,
                min: 1,
                max: 200,
                value: radius,
                eventResMs: eventResMs,
                onChange: (val): void => {
                    radius = val;
                    fxCanvas.draw(texture).triangleBlur(radius * previewFactor).update();
                    klCanvasPreview.render();
                },
            });
            div.append(radiusSlider.getElement());

            const previewWrapper = BB.el({
                className: 'kl-preview-wrapper',
                css: {
                    width: '340px',
                    height: '220px',
                },
            });

            const previewLayerArr: IKlBasicLayer[] = [];
            {
                for (let i = 0; i < layers.length; i++) {
                    previewLayerArr.push({
                        image: i === selectedLayerIndex ? fxCanvas : layers[i].context.canvas,
                        isVisible: layers[i].isVisible,
                        opacity: layers[i].opacity,
                        mixModeStr: layers[i].mixModeStr,
                    });
                }
            }
            const klCanvasPreview = new KlCanvasPreview({
                width: parseInt('' + displayW),
                height: parseInt('' + displayH),
                layers: previewLayerArr,
            });

            const previewInnerWrapper = BB.el({
                className: 'kl-preview-wrapper__canvas',
                css: {
                    width: parseInt('' + displayW) + 'px',
                    height: parseInt('' + displayH) + 'px',
                },
            });
            previewInnerWrapper.append(klCanvasPreview.getElement());
            previewWrapper.append(previewInnerWrapper);

            div.append(previewWrapper);

            try {
                fxCanvas.draw(texture).triangleBlur(radius * previewFactor).update();
                klCanvasPreview.render();
            } catch(e) {
                (div as any).errorCallback(e);
            }

            result.destroy = (): void => {
                texture.destroy();
                radiusSlider.destroy();
                klCanvasPreview.destroy();
            };
            result.getInput = function (): TFilterBlurInput {
                result.destroy!();
                return {
                    radius: radius,
                };
            };
        }

        setTimeout(finishInit, 1);

        return result;
    },

    apply (params: IFilterApply<TFilterBlurInput>): boolean {
        const context = params.context;
        const history = params.history;
        const radius = params.input.radius;
        if (!context || !radius || !history) {
            return false;
        }
        history.pause(true);
        const fxCanvas = getSharedFx();
        if (!fxCanvas) {
            return false; // todo more specific error?
        }
        const texture = fxCanvas.texture(context.canvas);
        fxCanvas.draw(texture).triangleBlur(radius).update();
        context.clearRect(0, 0, context.canvas.width, context.canvas.height);
        context.drawImage(fxCanvas, 0, 0);
        texture.destroy();
        history.pause(false);
        history.push({
            tool: ['filter', 'blur'],
            action: 'apply',
            params: [{
                input: params.input,
            }],
        } as TFilterBlurHistoryEntry);
        return true;
    },

};


code:
import {BB} from '../../bb/bb';
import {KlSlider} from '../ui/components/kl-slider';
import {eventResMs} from './filters-consts';
import {KlCanvasPreview} from '../canvas-ui/canvas-preview';
import {getSharedFx} from '../../fx-canvas/shared-fx';
import {IFilterApply, IFilterGetDialogParam, IFilterGetDialogResult, IKlBasicLayer} from '../kl-types';
import {LANG} from '../../language/language';
import {TFilterHistoryEntry} from './filters';

export type TFilterBrightnessContrastInput = {
    brightness: number;
    contrast: number;
}

export type TFilterBrightnessContrastHistoryEntry = TFilterHistoryEntry<
    'brightnessContrast',
    TFilterBrightnessContrastInput>;

export const filterBrightnessContrast = {
    getDialog (params: IFilterGetDialogParam) {

        const div = document.createElement('div');
        const result: IFilterGetDialogResult<TFilterBrightnessContrastInput> = {
            element: div,
        };

        const context = params.context;
        const klCanvas = params.klCanvas;
        if (!context || !klCanvas) {
            return false;
        }

        const layers = klCanvas.getLayers();
        const selectedLayerIndex = klCanvas.getLayerIndex(context.canvas);

        const fit = BB.fitInto(context.canvas.width, context.canvas.height, 280, 200, 1);
        const w = parseInt('' + fit.width), h = parseInt('' + fit.height);

        const tempCanvas = BB.canvas(w, h);
        {
            const ctx = BB.ctx(tempCanvas);
            ctx.save();
            if (tempCanvas.width > context.canvas.width) {
                ctx.imageSmoothingEnabled = false;
            }
            ctx.drawImage(context.canvas, 0, 0, w, h);
            ctx.restore();
        }

        function finishInit (): void {

            let brightness = 0, contrast = 0;
            div.innerHTML = LANG('filter-bright-contrast-description') + '

';

            const fxCanvas = getSharedFx();
            if (!fxCanvas) {
                return; // todo throw?
            }
            const texture = fxCanvas.texture(tempCanvas);
            fxCanvas.draw(texture).update(); // update fxCanvas size

            const brightnessSlider = new KlSlider({
                label: LANG('filter-bright-contrast-brightness'),
                width: 300,
                height: 30,
                min: 0,
                max: 100,
                value: (brightness + 1) * 50,
                eventResMs: eventResMs,
                onChange: function (val) {
                    brightness = val / 50 - 1;
                    fxCanvas.draw(texture).brightnessContrast(brightness, contrast).update();
                    klCanvasPreview.render();
                },
            });
            const contrastSlider = new KlSlider({
                label: LANG('filter-bright-contrast-contrast'),
                width: 300,
                height: 30,
                min: 0,
                max: 100,
                value: (contrast + 1) * 50,
                eventResMs: eventResMs,
                onChange: function (val) {
                    contrast = val / 50 - 1;
                    fxCanvas.draw(texture).brightnessContrast(brightness, contrast).update();
                    klCanvasPreview.render();
                },
            });
            brightnessSlider.getElement().style.marginBottom = '10px';
            div.append(brightnessSlider.getElement(), contrastSlider.getElement());

            const previewWrapper = BB.el({
                className: 'kl-preview-wrapper',
                css: {
                    width: '340px',
                    height: '220px',
                },
            });

            const previewLayerArr: IKlBasicLayer[] = [];
            {
                for (let i = 0; i < layers.length; i++) {
                    previewLayerArr.push({
                        image: i === selectedLayerIndex ? fxCanvas : layers[i].context.canvas,
                        isVisible: layers[i].isVisible,
                        opacity: layers[i].opacity,
                        mixModeStr: layers[i].mixModeStr,
                    });
                }
            }
            const klCanvasPreview = new KlCanvasPreview({
                width: parseInt('' + w),
                height: parseInt('' + h),
                layers: previewLayerArr,
            });

            const previewInnerWrapper = BB.el({
                className: 'kl-preview-wrapper__canvas',
                css: {
                    width: parseInt('' + w) + 'px',
                    height: parseInt('' + h) + 'px',
                },
            });
            previewInnerWrapper.append(klCanvasPreview.getElement());
            previewWrapper.append(previewInnerWrapper);

            div.append(previewWrapper);

            try {
                fxCanvas.draw(texture).brightnessContrast(brightness, contrast).update();
                klCanvasPreview.render();
            } catch(e) {
                (div as any).errorCallback(e);
            }

            result.destroy = () => {
                brightnessSlider.destroy();
                contrastSlider.destroy();
                texture.destroy();
                klCanvasPreview.destroy();
            };
            result.getInput = function (): TFilterBrightnessContrastInput {
                result.destroy!();
                return {
                    brightness: brightness,
                    contrast: contrast,
                };
            };
        }

        setTimeout(finishInit, 1); // the canvas isn't ready for some reason

        return result;
    },

    apply (params: IFilterApply<TFilterBrightnessContrastInput>): boolean {
        const context = params.context;
        const brightness = params.input.brightness;
        const contrast = params.input.contrast;
        const history = params.history;
        if (!context || !history) {
            return false;
        }
        history.pause(true);
        const fxCanvas = getSharedFx();
        if (!fxCanvas) {
            return false; // todo more specific error?
        }
        const texture = fxCanvas.texture(context.canvas);
        fxCanvas.draw(texture).brightnessContrast(brightness, contrast).update();
        context.clearRect(0, 0, context.canvas.width, context.canvas.height);
        context.drawImage(fxCanvas, 0, 0);
        texture.destroy();
        history.pause(false);
        history.push({
            tool: ['filter', 'brightnessContrast'],
            action: 'apply',
            params: [{
                input: params.input,
            }],
        } as TFilterBrightnessContrastHistoryEntry);

        return true;
    },
};


code:
import {BB} from '../../bb/bb';
import {input} from '../ui/components/input';
import {Checkbox} from '../ui/components/checkbox';
import {ColorOptions} from '../ui/components/color-options';
import {Cropper} from '../ui/components/cropper';
import {IFilterApply, IFilterGetDialogParam, IFilterGetDialogResult, IRGBA} from '../kl-types';
import {LANG} from '../../language/language';
import {TFilterHistoryEntry} from './filters';
import {theme} from '../../theme/theme';
import {IRect} from '../../bb/bb-types';

export type TFilterCropExtendInput = {
    left: number;
    right: number;
    top: number;
    bottom: number;
    fillColor?: IRGBA;
};

export type TFilterCropExtendHistoryEntry = TFilterHistoryEntry<
    'cropExtend',
    TFilterCropExtendInput>;

export const filterCropExtend = {

    getDialog (params: IFilterGetDialogParam) {
        const klCanvas = params.klCanvas;
        if (!klCanvas) {
            return false;
        }
        const tempCanvas = BB.canvas();
        {
            const fit = BB.fitInto(klCanvas.getWidth(), klCanvas.getHeight(), 560, 400, 1);
            const w = parseInt('' + fit.width), h = parseInt('' + fit.height);
            const previewFactor = w / klCanvas.getWidth();
            tempCanvas.width = w;
            tempCanvas.height = h;
            tempCanvas.style.display = 'block';
            BB.ctx(tempCanvas).drawImage(klCanvas.getCompleteCanvas(previewFactor), 0, 0, w, h);
        }

        const div = document.createElement('div');
        const result: IFilterGetDialogResult<TFilterCropExtendInput> = {
            element: div,
        };
        div.innerHTML = LANG('filter-crop-description') + '

';
        let left = 0, right = 0, top = 0, bottom = 0;
        let leftChanged = false, rightChanged = false, topChanged = false, bottomChanged = false;
        const maxWidth = params.maxWidth, maxHeight = params.maxHeight;
        let scale: number = 1;

        // --- input elements ---

        const lrWrapper = BB.el({
            css: {lineHeight: '30px', height: '35px'},
        });
        const tbWrapper = BB.el({
            css: {lineHeight: '30px', height: '35px'},
        });
        div.append(lrWrapper, tbWrapper);

        const leftInput = input({
            init: 0,
            type: 'number',
            min: -klCanvas.getWidth(),
            max: maxWidth,
            css: {width: '75px', marginRight: '20px'},
            callback: function () {
                leftChanged = true;
                updateInput();
            },
        });
        const rightInput = input({
            init: 0,
            type: 'number',
            min: -klCanvas.getWidth(),
            max: maxWidth,
            css: {width: '75px'},
            callback: function () {
                rightChanged = true;
                updateInput();
            },
        });
        const topInput = input({
            init: 0,
            type: 'number',
            min: -klCanvas.getHeight(),
            max: maxHeight,
            css: {width: '75px', marginRight: '20px'},
            callback: function () {
                topChanged = true;
                updateInput();
            },
        });
        const bottomInput = input({
            init: 0,
            type: 'number',
            min: -klCanvas.getHeight(),
            max: maxHeight,
            css: {width: '75px'},
            callback: function () {
                bottomChanged = true;
                updateInput();
            },
        });

        const labelStyle = {
            display: 'inline-block',
            width: '60px',
        };
        lrWrapper.append(
            BB.el({content: LANG('filter-crop-left') + ':', css: labelStyle}),
            leftInput,
            BB.el({content: LANG('filter-crop-right') + ':', css: labelStyle}),
            rightInput
        );
        tbWrapper.append(
            BB.el({content: LANG('filter-crop-top') + ':', css: labelStyle}),
            topInput,
            BB.el({content: LANG('filter-crop-bottom') + ':', css: labelStyle}),
            bottomInput
        );

        function updateInput (): void {
            left = parseInt(leftInput.value);
            right = parseInt(rightInput.value);
            top = parseInt(topInput.value);
            bottom = parseInt(bottomInput.value);
            let newWidth = klCanvas.getWidth() + left + right;
            let newHeight = klCanvas.getHeight() + top + bottom;

            if (newWidth <= 0) {
                if (leftChanged) {
                    left = -klCanvas.getWidth() - right + 1;
                    leftInput.value = '' + left;
                }
                if (rightChanged) {
                    right = -klCanvas.getWidth() - left + 1;
                    rightInput.value = '' + right;
                }
                newWidth = 1;
            }
            if (newWidth > maxWidth) {
                if (leftChanged) {
                    left = -klCanvas.getWidth() - right + maxWidth;
                    leftInput.value = '' + left;
                }
                if (rightChanged) {
                    right = -klCanvas.getWidth() - left + maxWidth;
                    rightInput.value = '' + right;
                }
                newWidth = maxWidth;
            }
            if (newHeight <= 0) {
                if (topChanged) {
                    top = -klCanvas.getHeight() - bottom + 1;
                    topInput.value = '' + top;
                }
                if (bottomChanged) {
                    bottom = -klCanvas.getHeight() - top + 1;
                    bottomInput.value = '' + bottom;
                }
                newHeight = 1;
            }
            if (newHeight > maxHeight) {
                if (topChanged) {
                    top = -klCanvas.getHeight() - bottom + maxHeight;
                    topInput.value = '' + top;
                }
                if (bottomChanged) {
                    bottom = -klCanvas.getHeight() - top + maxHeight;
                    bottomInput.value = '' + bottom;
                }
                newHeight = maxHeight;
            }
            cropper.setTransform({
                x: -left,
                y: -top,
                width: newWidth,
                height: newHeight,
            });

            leftChanged = false;
            rightChanged = false;
            topChanged = false;
            bottomChanged = false;
        }

        let useRuleOfThirds = true;
        const ruleOThirdsCheckbox = new Checkbox({
            init: true,
            label: LANG('filter-crop-rule-thirds'),
            allowTab: true,
            callback: function (b) {
                useRuleOfThirds = b;
                cropper.showThirds(useRuleOfThirds);
            },
        });
        div.append(BB.el({
            css: {
                clear: 'both',
            },
        }));

        let selectedRgbaObj = {r: 0, g: 0, b: 0, a: 0};
        const colorOptionsArr = [
            {r: 0, g: 0, b: 0, a: 0},
            {r: 255, g: 255, b: 255, a: 1},
            {r: 0, g: 0, b: 0, a: 1},
        ];
        colorOptionsArr.push({
            r: params.currentColorRgb.r,
            g: params.currentColorRgb.g,
            b: params.currentColorRgb.b,
            a: 1,
        });
        colorOptionsArr.push({
            r: params.secondaryColorRgb.r,
            g: params.secondaryColorRgb.g,
            b: params.secondaryColorRgb.b,
            a: 1,
        });

        const colorOptions = new ColorOptions({
            label: LANG('filter-crop-fill'),
            colorArr: colorOptionsArr,
            onChange: function (rgbaObj) {
                selectedRgbaObj = rgbaObj!;
                updateBg();
            },
        });

        const flexRow = BB.el({
            css: {
                display: 'flex',
                justifyContent: 'space-between',
                alignItems: 'center',
                marginTop: '10px',
            },
        });
        div.append(flexRow);
        flexRow.append(ruleOThirdsCheckbox.getElement(), colorOptions.getElement());

        // when input field changed, or dragging in preview finished
        // adjusts the zoom
        function update (transform: IRect): void {
            const fit = BB.fitInto(transform.width, transform.height, 260, 180, 1);
            scale = fit.width / transform.width;

            const offset = BB.centerWithin(340, previewHeight, fit.width, fit.height);

            tempCanvas.style.width = klCanvas.getWidth() * scale + 'px';
            tempCanvas.style.height = klCanvas.getHeight() * scale + 'px';

            offsetWrapper.style.left = (offset.x - transform.x * scale) + 'px';
            offsetWrapper.style.top = (offset.y - transform.y * scale) + 'px';

            left = parseInt('' + -transform.x);
            top = parseInt('' + -transform.y);
            right = parseInt('' + (transform.x + transform.width - klCanvas.getWidth()));
            bottom = parseInt('' + (transform.y + transform.height - klCanvas.getHeight()));
            leftInput.value = '' + left;
            topInput.value = '' + top;
            rightInput.value = '' + right;
            bottomInput.value = '' + bottom;

            BB.createCheckerDataUrl(parseInt('' + (50 * scale)), function (url) {
                previewWrapper.style.background = 'url(' + url + ')';
                if (selectedRgbaObj.a !== 0) {
                    tempCanvas.style.background = 'url(' + url + ')';
                }
            }, theme.isDark());
            previewWrapper.style.backgroundPosition = (offset.x) + 'px ' + (offset.y) + 'px';

            cropper.setScale(scale);
        }

        const previewHeight = 218; // two less because of border
        const previewWrapper = BB.el({
            className: 'kl-edit-crop-preview',
            css: {
                width: '340px',
                marginTop: '10px',
                marginLeft: '-20px',
                height: previewHeight + 'px',
                backgroundColor: '#9e9e9e',
                position: 'relative',
                borderTop: '1px solid rgb(144,144,144)',
                borderBottom: '1px solid rgb(144,144,144)',
                overflow: 'hidden',
                userSelect: 'none',
                touchAction: 'none',
            },
        });
        previewWrapper.oncontextmenu = function () {
            return false;
        };
        const bgColorOverlay = BB.el({
            css: {
                position: 'absolute',
                left: '0',
                top: '0',
                bottom: '0',
                right: '0',
            },
        });
        previewWrapper.append(bgColorOverlay);

        const offsetWrapper = document.createElement('div');
        offsetWrapper.style.position = 'absolute';
        offsetWrapper.style.left = '0px';
        offsetWrapper.style.top = '0px';
        previewWrapper.append(offsetWrapper);

        BB.el({
            parent: offsetWrapper,
            content: tempCanvas,
            css: {
                boxShadow: '0 0 0px 1px rgb(130,130,130)',
                position: 'absolute',
                left: '0px',
                top: '0px',
            },
        });

        div.append(previewWrapper);
        const cropper = new Cropper({
            x: 0,
            y: 0,
            width: klCanvas.getWidth(),
            height: klCanvas.getHeight(),
            scale: scale,
            callback: update,
            maxW: maxWidth,
            maxH: maxHeight,
        });
        update(cropper.getTransform());
        offsetWrapper.append(cropper.getElement());

        function updateBg (): void {
            if (selectedRgbaObj.a === 0) {
                bgColorOverlay.style.background = '';
                tempCanvas.style.background = '';
            } else {
                bgColorOverlay.style.background = BB.ColorConverter.toRgbStr(selectedRgbaObj);

                BB.createCheckerDataUrl(parseInt('' + (50 * scale)), function (url) {
                    tempCanvas.style.background = 'url(' + url + ')';
                }, theme.isDark());
            }
        }

        function updateIsDark (): void {
            updateInput();
        }
        theme.addIsDarkListener(updateIsDark);

        result.destroy = (): void => {
            cropper.destroy();
            ruleOThirdsCheckbox.destroy();
            theme.removeIsDarkListener(updateIsDark);
            colorOptions.destroy();
        };
        result.getInput = function (): TFilterCropExtendInput {
            result.destroy!();
            return {
                left: left,
                right: right,
                top: top,
                bottom: bottom,
                fillColor: selectedRgbaObj.a === 0 ? undefined : selectedRgbaObj,
            };
        };
        return result;
    },

    apply (params: IFilterApply<TFilterCropExtendInput>): boolean {
        const klCanvas = params.klCanvas;
        const history = params.history;
        if (
            !klCanvas ||
            !history ||
            isNaN(params.input.left) ||
            isNaN(params.input.right) ||
            isNaN(params.input.top) ||
            isNaN(params.input.bottom)
        ) {
            return false;
        }
        history.pause(true);
        klCanvas.resizeCanvas(params.input);
        history.pause(false);
        history.push({
            tool: ['filter', 'cropExtend'],
            action: 'apply',
            params: [{
                input: BB.copyObj(params.input),
            }],
        } as TFilterCropExtendHistoryEntry);
        return true;
    },

};


code:
import {BB} from '../../bb/bb';
import {KlCanvasPreview} from '../canvas-ui/canvas-preview';
import {getSharedFx} from '../../fx-canvas/shared-fx';
import {IFilterApply, IFilterGetDialogParam, IFilterGetDialogResult, IKlBasicLayer} from '../kl-types';
import {LANG} from '../../language/language';
import {CurvesInput, getDefaultCurvesInput, TCurvesInput} from './filter-curves/curves-input';
import {Options} from '../ui/components/options';
import {TFilterHistoryEntry} from './filters';

export type TFilterCurvesInput = {
    curves: TCurvesInput;
};

export type TFilterCurvesHistoryEntry = TFilterHistoryEntry<
    'curves',
    TFilterCurvesInput>;

export const filterCurves = {

    getDialog (params: IFilterGetDialogParam) {

        const context = params.context;
        const klCanvas = params.klCanvas;
        if (!context || !klCanvas) {
            return false;
        }

        const layers = klCanvas.getLayers();
        const selectedLayerIndex = klCanvas.getLayerIndex(context.canvas);

        const fit = BB.fitInto(context.canvas.width, context.canvas.height, 280, 200, 1);
        const w = parseInt('' + fit.width), h = parseInt('' + fit.height);

        const tempCanvas = BB.canvas(w, h);
        {
            const ctx = BB.ctx(tempCanvas);
            ctx.save();
            if (tempCanvas.width > context.canvas.width) {
                ctx.imageSmoothingEnabled = false;
            }
            ctx.drawImage(context.canvas, 0, 0, w, h);
            ctx.restore();
        }

        const div = BB.el();
        const result: IFilterGetDialogResult<TFilterCurvesInput> = {
            element: div,
        };
        let klCanvasPreview: KlCanvasPreview;

        function finishInit (): void {

            div.innerHTML = LANG('filter-curves-description') + '

';

            let curves: TCurvesInput = getDefaultCurvesInput();

            const fxCanvas = getSharedFx();
            if (fxCanvas === null) {
                return; // todo throw?
            }
            const texture = fxCanvas.texture(tempCanvas);
            fxCanvas.draw(texture).update(); // update fxCanvas size

            function update (): void {
                try {
                    fxCanvas!.draw(texture).curves(curves.r, curves.g, curves.b).update();
                    if (klCanvasPreview) {
                        klCanvasPreview.render();
                    }
                } catch(e) {
                    (div as any).errorCallback(e);
                }
            }

            const input = new CurvesInput({
                curves,
                callback: function (val) {
                    curves = val;
                    update();
                },
            });
            const modeButtons: Options<string> = input.getModeButtons();

            div.append(input.getElement());

            const previewWrapper = BB.el({
                className: 'kl-preview-wrapper',
                css: {
                    width: '340px',
                    height: '220px',
                },
            });

            const previewLayerArr: IKlBasicLayer[] = [];
            {
                for (let i = 0; i < layers.length; i++) {
                    previewLayerArr.push({
                        image: i === selectedLayerIndex ? fxCanvas : layers[i].context.canvas,
                        isVisible: layers[i].isVisible,
                        opacity: layers[i].opacity,
                        mixModeStr: layers[i].mixModeStr,
                    });
                }
            }
            klCanvasPreview = new KlCanvasPreview({
                width: parseInt('' + w),
                height: parseInt('' + h),
                layers: previewLayerArr,
            });

            const previewInnerWrapper = BB.el({
                className: 'kl-preview-wrapper__canvas',
                css: {
                    width: parseInt('' + w) + 'px',
                    height: parseInt('' + h) + 'px',
                },
            });
            previewInnerWrapper.append(klCanvasPreview.getElement());
            previewWrapper.append(previewInnerWrapper);

            div.append(previewWrapper);

            result.destroy = (): void => {
                input.destroy();
                texture.destroy();
                modeButtons.destroy();
                klCanvasPreview.destroy();
            };
            result.getInput = function (): TFilterCurvesInput {
                result.destroy!();
                return {
                    curves: curves,
                };
            };
        }

        setTimeout(finishInit, 1);

        return result;
    },

    apply (params: IFilterApply<TFilterCurvesInput>): boolean {
        const context = params.context;
        const curves = params.input.curves;
        const history = params.history;
        if (!context || !history) {
            return false;
        }
        history.pause(true);
        const fxCanvas = getSharedFx();
        if (!fxCanvas) {
            return false; // todo more specific error?
        }
        const texture = fxCanvas.texture(context.canvas);
        fxCanvas.draw(texture).curves(curves.r, curves.g, curves.b).update();
        context.clearRect(0, 0, context.canvas.width, context.canvas.height);
        context.drawImage(fxCanvas, 0, 0);
        texture.destroy();
        history.pause(false);
        history.push({
            tool: ['filter', 'curves'],
            action: 'apply',
            params: [{
                input: params.input,
            }],
        } as TFilterCurvesHistoryEntry);

        return true;
    },

};


code:
import {Options} from '../../ui/components/options';
import {LANG} from '../../../language/language';
import {BB} from '../../../bb/bb';
import {IVector2D} from '../../../bb/bb-types';
import {PointerListener} from '../../../bb/input/pointer-listener';

type TCurvePoint = {
    el: HTMLElement;
    pointerListener: PointerListener;
    setPos: (newX: number, newY: number) => void;
};

export type TCurvesInput = {
    r: [number, number][];
    g: [number, number][];
    b: [number, number][];
}

export function getDefaultCurvesInput (): TCurvesInput {
    return BB.copyObj({
        r: [[0, 0], [1 / 3, 1 / 3], [2 / 3, 2 / 3], [1, 1]],
        g: [[0, 0], [1 / 3, 1 / 3], [2 / 3, 2 / 3], [1, 1]],
        b: [[0, 0], [1 / 3, 1 / 3], [2 / 3, 2 / 3], [1, 1]],
    });
}

export class CurvesInput {

    private readonly rootEl: HTMLElement;
    private readonly p0: TCurvePoint;
    private readonly p1: TCurvePoint;
    private readonly p2: TCurvePoint;
    private readonly p3: TCurvePoint;
    private readonly modeButtons: Options<string>;

    // ---- public ----
    constructor (
        p: {
            curves: TCurvesInput;
            callback: (val: TCurvesInput) => void;
        },
    ) {
        this.rootEl = BB.el({
            css: {
                position: 'relative',
                marginBottom: '10px',
            },
        });
        this.rootEl.oncontextmenu = () => false;
        let mode = 'All';
        let curves = p.curves;
        this.modeButtons = new Options( {
            optionArr: [
                {
                    id: 'All',
                    label: LANG('filter-curves-all'),
                },
                {
                    id: 'Red',
                    label: LANG('red'),
                },
                {
                    id: 'Green',
                    label: LANG('green'),
                },
                {
                    id: 'Blue',
                    label: LANG('blue'),
                },
            ],
            initId: 'All',
            onChange: (id) => {
                mode = id;
                if (mode === 'All') {
                    curves = getDefaultCurvesInput();
                }
                let curve = curves.r;
                if (mode === 'Green') {
                    curve = curves.g;
                }
                if (mode === 'Blue') {
                    curve = curves.b;
                }
                this.p0.setPos(0, areaH - curve[0][1] * areaH);
                this.p1.setPos(curve[1][0] * areaW, areaH - curve[1][1] * areaH);
                this.p2.setPos(curve[2][0] * areaW, areaH - curve[2][1] * areaH);
                this.p3.setPos(areaW, areaH - curve[3][1] * areaH);

                update();
            },
        });
        this.rootEl.append(this.modeButtons.getElement());

        const curveArea = BB.el({
            parent: this.rootEl,
            className: 'kl-curves-graph',
            css: {
                position: 'relative',
                marginTop: '10px',
            },
        });

        const areaW = 300, areaH = 100;
        const canvas = BB.canvas(areaW, areaH);
        let ctx = BB.ctx(canvas);
        curveArea.append(canvas);

        const fit = (v: number): number => {
            return Math.max(0, Math.min(1, v));
        };

        const createPoint = (
            x: number,
            y: number,
            callback: (p: IVector2D) => void,
            lock?: boolean,
        ): TCurvePoint => {
            const gripSize = 14;
            let internalY = y, internalX = x;
            const pointEl = BB.el({
                className: 'kl-curves-graph__grip',
                css: {
                    left: (x - gripSize / 2) + 'px',
                    top: (y - gripSize / 2) + 'px',
                    width: gripSize + 'px',
                    height: gripSize + 'px',
                    borderRadius: gripSize + 'px',
                },
            });

            const update = () => {
                BB.css(pointEl, {
                    left: (x - gripSize / 2) + 'px',
                    top: (y - gripSize / 2) + 'px',
                });
            };

            const pointerListener = new BB.PointerListener({
                target: pointEl,
                onPointer: (event) => {
                    event.eventPreventDefault();
                    if (event.type === 'pointerdown') {
                        internalX = x;
                        internalY = y;
                    }
                    if (event.button === 'left' && event.type === 'pointermove') {
                        if (!lock) {
                            internalX += event.dX;
                        }
                        x = Math.max(0, Math.min(areaW, internalX));
                        internalY += event.dY;
                        y = Math.max(0, Math.min(areaH, internalY));
                        update();
                        callback({
                            x: x,
                            y: y,
                        });
                    }
                },
            });

            curveArea.append(pointEl);

            const setPos = (newX: number, newY: number): void => {
                x = newX;
                y = newY;
                internalY = y;
                internalX = x;
                BB.css(pointEl, {
                    left: (x - gripSize / 2) + 'px',
                    top: (y - gripSize / 2) + 'px',
                });
            };

            return {
                el: pointEl,
                setPos,
                pointerListener,
            };
        };

        const updateControl = (i: number, x: number, y: number) => {
            if (mode === 'All') {
                curves.r[i] = [fit(x / areaW), fit(1 - y / areaH)];
                curves.g[i] = [fit(x / areaW), fit(1 - y / areaH)];
                curves.b[i] = [fit(x / areaW), fit(1 - y / areaH)];
            }
            if (mode === 'Red') {
                curves.r[i] = [fit(x / areaW), fit(1 - y / areaH)];
            }
            if (mode === 'Green') {
                curves.g[i] = [fit(x / areaW), fit(1 - y / areaH)];
            }
            if (mode === 'Blue') {
                curves.b[i] = [fit(x / areaW), fit(1 - y / areaH)];
            }
        };

        this.p0 = createPoint(0, areaH, (val: IVector2D) => {
            updateControl(0, val.x, val.y);
            update();
        }, true);
        this.p1 = createPoint(areaW / 3, areaH / 3 * 2, (val: IVector2D) => {
            updateControl(1, val.x, val.y);
            update();
        });
        this.p2 = createPoint(areaW / 3 * 2, areaH / 3, (val: IVector2D) => {
            updateControl(2, val.x, val.y);
            update();
        });
        this.p3 = createPoint(areaW, 0, (val: IVector2D) => {
            updateControl(3, val.x, val.y);
            update();
        }, true);

        const update = () => {
            ctx = BB.ctx(canvas);
            ctx.clearRect(0, 0, canvas.width, canvas.height);

            const outCurves: TCurvesInput = {
                r: [],
                g: [],
                b: [],
            };
            for (let i = 0; i < curves.r.length; i++) {
                outCurves.r.push(curves.r[i]);
                outCurves.g.push(curves.g[i]);
                outCurves.b.push(curves.b[i]);
            }

            const drawCurve = (curve: [number, number][]) => {
                ctx.beginPath();
                const spline = new BB.SplineInterpolator(curve);
                for (let i = 0; i < 100; i++) {
                    let y = spline.interpolate(i / 100);
                    y = Math.max(0, Math.min(1, y));

                    if (i === 0) {
                        ctx.moveTo(i / 100 * areaW, areaH - y * areaH);
                    } else {
                        ctx.lineTo(i / 100 * areaW, areaH - y * areaH);
                    }
                }
                ctx.stroke();
            };

            ctx.save();
            if (mode === 'All') {
                ctx.strokeStyle = 'black';
                drawCurve(outCurves.r);
            } else {
                ctx.globalAlpha = 0.5;
                ctx.strokeStyle = 'red';
                drawCurve(outCurves.r);
                ctx.strokeStyle = 'green';
                drawCurve(outCurves.g);
                ctx.strokeStyle = 'blue';
                drawCurve(outCurves.b);
            }
            ctx.restore();
            p.callback(outCurves);
        };

        update();
    }

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

    destroy (): void {
        this.p0.pointerListener.destroy();
        this.p1.pointerListener.destroy();
        this.p2.pointerListener.destroy();
        this.p3.pointerListener.destroy();
    }

    getModeButtons (): Options<string> {
        return this.modeButtons;
    }
}


code:
import {BB} from '../../bb/bb';
import {IFilterApply, IFilterGetDialogParam, IFilterGetDialogResult, IKlBasicLayer} from '../kl-types';
import {KlSlider} from '../ui/components/kl-slider';
import {LANG} from '../../language/language';
import {eventResMs} from './filters-consts';
import {KlCanvasPreview} from '../canvas-ui/canvas-preview';
import {getSharedFx} from '../../fx-canvas/shared-fx';
import {Options} from '../ui/components/options';
import {Checkbox} from '../ui/components/checkbox';
import {TFilterHistoryEntry} from './filters';
import {throwIfNull} from '../../bb/base/base';
import {TFilterDistortSettings} from '../../fx-canvas/filters/distort';

// see fx-canvas distort
export type TFilterDistortInput = {
    stepSize: number; // [0, inf]
    distortType: 0 | 1 | 2;
    scale: { x: number; y: number };
    strength: { x: number; y: number };
    phase: { x: number; y: number };
};

export type TFilterDistortHistoryEntry = TFilterHistoryEntry<
    'distort',
    TFilterDistortInput>;

export const filterDistort = {

    getDialog (params: IFilterGetDialogParam) {

        const isSmall = window.innerWidth < 550;
        const rootEl = BB.el({
            content: LANG('filter-distort-description') + '

',
        });
        const context = params.context;

        let isSynced = true;
        const settings: TFilterDistortInput = {
            distortType: 0,
            scale: {x: 100, y: 100},
            strength: {x: 20, y: 20},
            phase: {x: 0, y: 0},
            stepSize: 1,
        };
        // let lastDrawnSettings = null;

        // ---- thumb -------
        const thumbImgArr: HTMLImageElement[] = [];
        const thumbSize = 32;
        {
            const canvas = BB.canvas(thumbSize, thumbSize);
            const ctx = BB.ctx(canvas);

            ctx.beginPath();
            ctx.arc(thumbSize / 2, thumbSize / 2, thumbSize / 2.5, 0, Math.PI * 2);
            ctx.fill();

            let gradient = ctx.createLinearGradient(0, 0, thumbSize, thumbSize);
            gradient.addColorStop(0, '#00f');
            gradient.addColorStop(0.5, '#f00');
            gradient.addColorStop(1, '#fff');
            ctx.fillStyle = gradient;
            ctx.globalCompositeOperation = 'source-atop';
            ctx.fillRect(0, 0, thumbSize, thumbSize);

            gradient = ctx.createLinearGradient(thumbSize, 0, 0, thumbSize);
            gradient.addColorStop(0, '#000');
            gradient.addColorStop(1, '#000');
            ctx.fillStyle = gradient;
            ctx.globalCompositeOperation = 'destination-atop';
            ctx.fillRect(0, 0, thumbSize, thumbSize);

            const fxCanvas = throwIfNull(getSharedFx());
            const texture = fxCanvas.texture(canvas);
            fxCanvas.draw(texture).update(); // update fxCanvas size

            const scaleFactor = 20;

            ([0, 1, 2] as const).forEach(item => {
                const thumbImg = new Image();
                const settingsCopy: TFilterDistortSettings = BB.copyObj(settings);
                settingsCopy.distortType = item;
                settingsCopy.scale.x /= scaleFactor;
                settingsCopy.scale.y /= scaleFactor;
                settingsCopy.strength.x /= scaleFactor;
                settingsCopy.strength.y /= scaleFactor;
                fxCanvas.draw(texture).multiplyAlpha().distort(settingsCopy).unmultiplyAlpha().update();
                ctx.clearRect(0, 0, thumbSize, thumbSize);
                ctx.drawImage(fxCanvas, 0, 0);
                thumbImg.src = canvas.toDataURL('image/png');
                thumbImgArr.push(thumbImg);
            });

            texture.destroy();
        }

        // ---- controls ----

        const topRowEl = BB.el({
            parent: rootEl,
            css: {
                display: 'flex',
                alignItems: 'center',
            },
        });

        const typeOptions = new Options({
            optionArr: thumbImgArr.map((img, index) => {
                BB.css(img, {
                    margin: '1px',
                    borderRadius: '3px',
                    transition: 'all 0.1s ease-in-out',
                });
                return {
                    id: '' + index,
                    label: img,
                };
            }),
            initId: '0',
            onChange: (id) => {
                settings.distortType = Number(id) as TFilterDistortSettings['distortType'];
                updatePreview();
            },
        });

        function sync (from: 'x' | 'y') {
            if (from === 'x') {
                settings.scale.y = (settings.scale.x);
                settings.strength.y = (settings.strength.x);
                settings.phase.y = (settings.phase.x);

                sliderArr[3].setValue(settings.scale.y);
                sliderArr[4].setValue(settings.strength.y);
                sliderArr[5].setValue(settings.phase.y);
            } else {
                settings.scale.x = (settings.scale.y);
                settings.strength.x = (settings.strength.y);
                settings.phase.x = (settings.phase.y);

                sliderArr[0].setValue(settings.scale.x);
                sliderArr[1].setValue(settings.strength.x);
                sliderArr[2].setValue(settings.phase.x);
            }
            updatePreview();
        }

        const syncToggle = new Checkbox({
            init: true,
            label: LANG('filter-distort-sync-xy'),
            callback: (val) => {
                isSynced = val;
                if (isSynced) {
                    sync('x');
                }
            },
        });

        topRowEl.append(
            typeOptions.getElement(),
            BB.el({css: {flexGrow: '1'}}),
            syncToggle.getElement(),
        );

        const xyRowEl = BB.el({
            parent: rootEl,
            css: {
                display: 'flex',
                flexWrap: 'wrap',
            },
        });
        const leftCol = BB.el({
            parent: xyRowEl,
            css: {
                marginRight: '10px',
            },
        });
        const rightCol = BB.el({parent: xyRowEl});

        const sliderWidth = isSmall ? 300 : 245;
        const sliderArr: KlSlider[] = [];

        (['x', 'y'] as const).forEach((item, index) => {
            const targetEl = index === 0 ? leftCol : rightCol;

            const scaleSlider = new KlSlider({
                label: LANG('filter-noise-scale') + ' ' + item.toUpperCase(),
                width: sliderWidth,
                height: 30,
                min: 1,
                max: 1000,
                curve: 'quadratic',
                value: settings.scale[item],
                eventResMs: eventResMs,
                onChange: (val) => {
                    settings.scale[item] = val;
                    if (isSynced) {
                        sync(item);
                    } else {
                        updatePreview();
                    }
                },
            });
            scaleSlider.getElement().style.marginTop = '20px';
            targetEl.append(scaleSlider.getElement());

            const strengthSlider = new KlSlider({
                label: LANG('filter-unsharp-mask-strength') + ' ' + item.toUpperCase(),
                width: sliderWidth,
                height: 30,
                min: 0,
                max: 200,
                curve: 'quadratic',
                value: settings.strength[item],
                eventResMs: eventResMs,
                onChange: (val) => {
                    settings.strength[item] = val;
                    if (isSynced) {
                        sync(item);
                    } else {
                        updatePreview();
                    }
                },
            });
            strengthSlider.getElement().style.marginTop = '10px';
            targetEl.append(strengthSlider.getElement());

            const phaseSlider = new KlSlider({
                label: LANG('filter-distort-phase') + ' ' + item.toUpperCase(),
                width: sliderWidth,
                height: 30,
                min: 0,
                max: 1,
                value: settings.phase[item],
                manualInputRoundDigits: 2,
                eventResMs: eventResMs,
                formatFunc: (val) => BB.round(val, 2),
                onChange: (val) => {
                    settings.phase[item] = val;
                    if (isSynced) {
                        sync(item);
                    } else {
                        updatePreview();
                    }
                },
            });
            phaseSlider.getElement().style.marginTop = '10px';
            targetEl.append(phaseSlider.getElement());

            sliderArr.push(scaleSlider);
            sliderArr.push(strengthSlider);
            sliderArr.push(phaseSlider);
        });

        const stepSlider = new KlSlider({
            label: LANG('filter-distort-stepsize'),
            width: 300,
            height: 30,
            min: 1,
            max: 300,
            curve: 'quadratic',
            value: settings.stepSize,
            eventResMs: eventResMs,
            onChange: (val) => {
                settings.stepSize = Math.round(val);
                updatePreview();
            },
        });

        stepSlider.getElement().style.marginTop = '20px';
        rootEl.append(stepSlider.getElement());

        // ---- preview ----

        const klCanvas = params.klCanvas;
        const layers = klCanvas.getLayers();
        const selectedLayerIndex = throwIfNull(klCanvas.getLayerIndex(context.canvas));

        const fit = BB.fitInto(context.canvas.width, context.canvas.height, isSmall ? 280 : 490, isSmall ? 200 : 240, 1);
        const w = parseInt('' + fit.width), h = parseInt('' + fit.height);
        // const renderW = Math.min(w, context.canvas.width);
        // const renderH = Math.min(h, context.canvas.height);
        // const renderFactor = renderW / context.canvas.width;
        // const previewFactor = w / context.canvas.width;

        const previewWrapper = BB.el({
            className: 'kl-preview-wrapper',
            css: {
                width: isSmall ? '340px' : '540px',
                height: isSmall ? '260px' : '300px',
            },
        });

        const fxCanvas = throwIfNull(getSharedFx());
        const texture = fxCanvas.texture(context.canvas);
        fxCanvas.draw(texture).update(); // update fxCanvas size

        const previewLayer: IKlBasicLayer = {
            image: fxCanvas,
            isVisible: layers[selectedLayerIndex].isVisible,
            opacity: layers[selectedLayerIndex].opacity,
            mixModeStr: layers[selectedLayerIndex].mixModeStr,
        };
        const previewLayerArr = layers.map((item, i) => {
            if (i === selectedLayerIndex) {
                return previewLayer;
            } else {
                return {
                    image: item.context.canvas,
                    isVisible: item.isVisible,
                    opacity: item.opacity,
                    mixModeStr: item.mixModeStr,
                };
            }
        });
        const klCanvasPreview = new KlCanvasPreview({
            width: Math.round(w),
            height: Math.round(h),
            layers: previewLayerArr,
        });

        const previewInnerWrapper = BB.el({
            className: 'kl-preview-wrapper__canvas',
            css: {
                width: parseInt('' + w) + 'px',
                height: parseInt('' + h) + 'px',
            },
        });
        previewInnerWrapper.append(klCanvasPreview.getElement());
        previewWrapper.append(previewInnerWrapper);
        rootEl.append(previewWrapper);

        // ---------- rendering ---------------------

        function updatePreview (): void {
            fxCanvas.draw(texture).multiplyAlpha().distort(settings).unmultiplyAlpha().update();
            klCanvasPreview.render();
        }

        updatePreview();

        const destroy = () => {
            typeOptions.destroy();
            sliderArr.forEach(item => item.destroy());
            stepSlider.destroy();
            syncToggle.destroy();
            texture.destroy();
            klCanvasPreview.destroy();
        };

        // ----- result -------------------
        const result: IFilterGetDialogResult<TFilterDistortInput> = {
            element: rootEl,
            destroy,
            getInput: () => {
                destroy();
                return BB.copyObj(settings);
            },
        };
        if (!isSmall) {
            result.width = 500;
        }
        return result;
    },

    apply (params: IFilterApply<TFilterDistortInput>): boolean {
        const klCanvas = params.klCanvas;
        const context = params.context;
        const history = params.history;
        if (!klCanvas || !history) {
            return false;
        }
        history.pause(true);

        const fxCanvas = getSharedFx();
        if (!fxCanvas) {
            return false; // todo more specific error?
        }
        const texture = fxCanvas.texture(context.canvas);
        fxCanvas.draw(texture).multiplyAlpha().distort(params.input).unmultiplyAlpha().update();
        context.clearRect(0, 0, context.canvas.width, context.canvas.height);
        context.drawImage(fxCanvas, 0, 0);
        texture.destroy();

        history.pause(false);
        history.push({
            tool: ['filter', 'distort'],
            action: 'apply',
            params: [{
                input: params.input,
            }],
        } as TFilterDistortHistoryEntry);
        return true;
    },

};