code:
import {BB} from '../../bb/bb';
import {eventResMs} from './filters-consts';
import {KlSlider} from '../ui/components/kl-slider';
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 {IVector2D} from '../../bb/bb-types';
import {TFilterHistoryEntry} from './filters';
import {PointerListener} from '../../bb/input/pointer-listener';
import {throwIfNull} from '../../bb/base/base';

export type TFilterTiltShiftInput = {
    a: IVector2D;
    b: IVector2D;
    blur: number;
    gradient: number;
};

export type TFilterTiltShiftHistoryEntry = TFilterHistoryEntry<
    'tiltShift',
    TFilterTiltShiftInput>;

type TDraggable = HTMLDivElement & {
    x: number;
    y: number;
    // copy: (p: IVector2D) => void;
};

export const filterTiltShift = {

    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 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 displayPreviewFactor = displayW / context.canvas.width;

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

        const pointerListenerArr: PointerListener[] = [];

        function finishInit () {
            let blur = 20, gradient = 200;
            rootEl.innerHTML = LANG('filter-tilt-shift-description') + '

';

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

            function update () {
                try {
                    fxCanvas.draw(texture).tiltShift(
                        fa.x / displayPreviewFactor * previewFactor,
                        fa.y / displayPreviewFactor * previewFactor,
                        fb.x / displayPreviewFactor * previewFactor,
                        fb.y / displayPreviewFactor * previewFactor,
                        blur * previewFactor,
                        gradient * previewFactor
                    ).update();
                    klCanvasPreview.render();
                } catch(e) {
                    (rootEl as any).errorCallback(e);
                }
            }

            function createDraggable (x: number, y: number): TDraggable {
                const size = 14;
                const div = BB.el({
                    css: {
                        width: size + 'px',
                        height: size + 'px',
                        backgroundColor: '#fff',
                        boxShadow: 'inset 0 0 0 2px #000',
                        borderRadius: size + 'px',
                        position: 'absolute',
                        cursor: 'move',
                        left: (x - size / 2) + 'px',
                        top: (y - size / 2) + 'px',
                        userSelect: 'none',
                        touchAction: 'none',
                    },
                }) as TDraggable;
                div.x = x;
                div.y = y;
                const pointerListener = new BB.PointerListener({
                    target: div,
                    onPointer: function (event) {
                        event.eventPreventDefault();
                        if (event.button === 'left' && event.type === 'pointermove') {
                            div.x += event.dX;
                            div.y += event.dY;
                            div.style.left = (div.x - size / 2) + 'px';
                            div.style.top = (div.y - size / 2) + 'px';
                            update();
                        }
                    },
                });
                pointerListenerArr.push(pointerListener);
                return div;
            }

            // focus line control points
            const fa = createDraggable(parseInt('' + (displayW / 6)), parseInt('' + (displayH / 2)));
            const fb = createDraggable(parseInt('' + (displayW - displayW / 6)), parseInt('' + (displayH - displayH / 3)));

            const blurSlider = new KlSlider({
                label: LANG('filter-tilt-shift-blur'),
                width: 300,
                height: 30,
                min: 0,
                max: 200,
                value: blur,
                eventResMs: eventResMs,
                onChange: function (val) {
                    blur = val;
                    update();
                },
            });
            blurSlider.getElement().style.marginBottom = '10px';
            rootEl.append(blurSlider.getElement());
            const gradientSlider = new KlSlider({
                label: LANG('filter-tilt-shift-gradient'),
                width: 300,
                height: 30,
                min: 0,
                max: 1000,
                value: gradient,
                eventResMs: eventResMs,
                onChange: function (val) {
                    gradient = val;
                    update();
                },
            });
            rootEl.append(gradientSlider.getElement());

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

            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);

            previewInnerWrapper.append(fa, fb);

            rootEl.append(previewWrapper);
            update();
            result.destroy = (): void => {
                for (let i = 0; i < pointerListenerArr.length; i++) {
                    pointerListenerArr[i].destroy();
                }
                blurSlider.destroy();
                gradientSlider.destroy();
                texture.destroy();
                klCanvasPreview.destroy();
            };
            result.getInput = function (): TFilterTiltShiftInput {
                result.destroy!();
                return {
                    a: {x: fa.x / displayPreviewFactor, y: fa.y / displayPreviewFactor},
                    b: {x: fb.x / displayPreviewFactor, y: fb.y / displayPreviewFactor},
                    blur: blur,
                    gradient: gradient,
                };
            };
        }

        setTimeout(finishInit, 1);

        return result;
    },

    apply (params: IFilterApply<TFilterTiltShiftInput>): boolean {
        const context = params.context;
        const history = params.history;
        const a = params.input.a;
        const b = params.input.b;
        const blur = params.input.blur;
        const gradient = params.input.gradient;
        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).tiltShift(a.x, a.y, b.x, b.y, blur, gradient).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', 'tiltShift'],
            action: 'apply',
            params: [{
                input: params.input,
            }],
        } as TFilterTiltShiftHistoryEntry);
        return true;
    },

};


code:
import {BB} from '../../bb/bb';
import {Options} from '../ui/components/options';
import {ColorOptions} from '../ui/components/color-options';
import {KlCanvasPreview} from '../canvas-ui/canvas-preview';
import {getSharedFx} from '../../fx-canvas/shared-fx';
import {IFilterApply, IFilterGetDialogParam, IFilterGetDialogResult, IKlBasicLayer, IRGBA} from '../kl-types';
import {LANG} from '../../language/language';
import {TFilterHistoryEntry} from './filters';
import {throwIfNull} from '../../bb/base/base';

export type TFilterToAlphaInput = {
    sourceId: string;
    selectedRgbaObj: IRGBA | null;
};

export type TFilterToAlphaHistoryEntry = TFilterHistoryEntry<
    'toAlpha',
    TFilterToAlphaInput>;

export const filterToAlpha = {

    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 = document.createElement('div');
        const result: IFilterGetDialogResult<TFilterToAlphaInput> = {
            element: div,
        };

        function finishInit () {
            div.append(BB.el({
                content: LANG('filter-to-alpha-description'),
                css: {
                    marginBottom: '5px',
                },
            }));

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

            function updatePreview () {
                fxCanvas.draw(texture).toAlpha(sourceId === 'inverted-luminance', selectedRgbaObj).update();
                klCanvasPreview.render();
            }

            // source
            let sourceId = 'inverted-luminance';
            const sourceOptions = new Options({
                optionArr: [
                    {
                        id: 'inverted-luminance',
                        label: LANG('filter-to-alpha-inverted-lum'),
                    },
                    {
                        id: 'luminance',
                        label: LANG('filter-to-alpha-lum'),
                    },
                ],
                initId: sourceId,
                onChange: function (id) {
                    sourceId = id;
                    updatePreview();
                },
            });
            div.append(sourceOptions.getElement());

            // color
            let selectedRgbaObj: IRGBA | null = {r: 0, g: 0, b: 0, a: 1};
            const colorOptionsArr = [
                null,
                {r: 0, g: 0, b: 0, a: 1},
                {r: 255, g: 255, b: 255, a: 1},
                {
                    r: params.currentColorRgb.r,
                    g: params.currentColorRgb.g,
                    b: params.currentColorRgb.b,
                    a: 1,
                },
                {
                    r: params.secondaryColorRgb.r,
                    g: params.secondaryColorRgb.g,
                    b: params.secondaryColorRgb.b,
                    a: 1,
                },
            ];

            const colorOptions = new ColorOptions({
                label: LANG('filter-to-alpha-replace'),
                colorArr: colorOptionsArr,
                initialIndex: 1,
                onChange: function (rgbaObj) {
                    selectedRgbaObj = rgbaObj;
                    updatePreview();
                },
            });
            colorOptions.getElement().style.marginTop = '10px';
            div.append(colorOptions.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);

            setTimeout(function () { //ie has a problem otherwise...
                try {
                    updatePreview();
                } catch(e) {
                    (div as any).errorCallback(e);
                }
            }, 1);

            result.destroy = (): void => {
                texture.destroy();
                sourceOptions.destroy();
                klCanvasPreview.destroy();
            };
            result.getInput = function (): TFilterToAlphaInput {
                result.destroy!();
                return {
                    sourceId: sourceId,
                    selectedRgbaObj: selectedRgbaObj,
                };
            };
        }

        setTimeout(finishInit, 1);

        return result;
    },

    apply (params: IFilterApply<TFilterToAlphaInput>): boolean {
        const context = params.context;
        const history = params.history;
        const sourceId = params.input.sourceId;
        const selectedRgbaObj = params.input.selectedRgbaObj;
        if (!context || !sourceId || !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).toAlpha(sourceId === 'inverted-luminance', selectedRgbaObj).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', 'toAlpha'],
            action: 'apply',
            params: [{
                input: params.input,
            }],
        } as TFilterToAlphaHistoryEntry);
        return true;
    },

};

repo_name: Doggermelon/klecks

license: mit


code:
import {BB} from '../../bb/bb';
import {Checkbox} from '../ui/components/checkbox';
import {KlCanvasPreview} from '../canvas-ui/canvas-preview';
import {FreeTransform} from '../ui/components/free-transform';
import {IFreeTransform} from '../ui/components/free-transform-utils';
import {Select} from '../ui/components/select';
import {IFilterApply, IFilterGetDialogParam, IFilterGetDialogResult, IKlBasicLayer} from '../kl-types';
import {LANG} from '../../language/language';
import {TFilterHistoryEntry} from './filters';
import {throwIfNull} from '../../bb/base/base';

export type TFilterTransformInput = {
    bounds: {x: number; y: number; width: number; height: number};
    transform: IFreeTransform;
    isPixelated: boolean;
};

export type TFilterTransformHistoryEntry = TFilterHistoryEntry<
    'transform',
    TFilterTransformInput>;

export const filterTransform = {

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

        const isSmall = window.innerWidth < 550;
        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 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 displayPreviewFactor = displayW / context.canvas.width;

        // determine bounds and initial transformation
        const boundsObj = BB.canvasBounds(context);
        if (!boundsObj) {
            alert(LANG('filter-transform-empty'));
            return false;
        }
        const initTransform = {
            x: boundsObj.x + boundsObj.width / 2,
            y: boundsObj.y + boundsObj.height / 2,
            width: boundsObj.width,
            height: boundsObj.height,
            angleDeg: 0,
        };

        const div = document.createElement('div');
        const result: IFilterGetDialogResult<TFilterTransformInput> = {
            element: div,
        };
        if (!isSmall) {
            result.width = 500;
        }
        div.innerHTML = LANG('filter-transform-description');

        const keyListener = new BB.KeyListener({
            onDown: function (keyStr) {
                if (BB.isInputFocused(true)) {
                    return;
                }

                if (keyStr === 'left') {
                    inputX.value = '' + (parseFloat(inputX.value) - 1);
                    onInputsChanged();
                }
                if (keyStr === 'right') {
                    inputX.value = '' + (parseFloat(inputX.value) + 1);
                    onInputsChanged();
                }
                if (keyStr === 'up') {
                    inputY.value = '' + (parseFloat(inputY.value) - 1);
                    onInputsChanged();
                }
                if (keyStr === 'down') {
                    inputY.value = '' + (parseFloat(inputY.value) + 1);
                    onInputsChanged();
                }
            },

        });

        const leftWrapper = document.createElement('div');
        const rightWrapper = document.createElement('div');
        const rotWrapper = document.createElement('div');
        const inputY = document.createElement('input');
        const inputX = document.createElement('input');
        const inputR = document.createElement('input');
        leftWrapper.style.width = '100px';
        leftWrapper.style.height = '30px';
        rightWrapper.style.width = '100px';
        rightWrapper.style.height = '30px';
        rightWrapper.style.display = 'inline-block';
        leftWrapper.style.display = 'inline-block';
        rotWrapper.style.display = 'inline-block';
        rotWrapper.style.width = '150px';
        rotWrapper.style.height = '30px';
        inputY.type = 'number';
        inputX.type = 'number';
        inputR.type = 'number';
        inputX.style.width = 70 + 'px';
        inputY.style.width = 70 + 'px';
        inputR.style.width = 70 + 'px';
        inputY.value = '0';
        inputX.value = '0';
        inputR.value = '0';
        inputY.onclick = function () {
            inputY.focus();
            onInputsChanged();
        };
        inputX.onclick = function () {
            inputX.focus();
            onInputsChanged();
        };
        inputR.onclick = function () {
            inputR.focus();
            onInputsChanged();
        };
        inputY.onchange = function () {
            onInputsChanged();
        };
        inputX.onchange = function () {
            onInputsChanged();
        };
        inputR.onchange = function () {
            onInputsChanged();
        };
        inputY.onkeyup = function () {
            onInputsChanged();
        };
        inputX.onkeyup = function () {
            onInputsChanged();
        };
        inputR.onkeyup = function () {
            onInputsChanged();
        };
        leftWrapper.append('X: ', inputX);
        rightWrapper.append('Y: ', inputY);
        rotWrapper.append(LANG('filter-transform-rotation') + ': ', inputR);
        if (!isSmall) {
            const inputRow = BB.el({
                parent: div,
                css: {
                    marginTop: '10px',
                },
            });
            inputRow.append(leftWrapper, rightWrapper, rotWrapper);
        }

        // buttons
        const actionBtnCss = {
            marginLeft: '10px',
            marginTop: '10px',
        };
        const buttonRow = BB.el ({
            parent: div,
            css: {
                display: 'flex',
                flexWrap: 'wrap',
                marginLeft: '-10px',
            },
        });
        const flipXBtn = BB.el ({
            parent: buttonRow,
            tagName: 'button',
            content: LANG('filter-transform-flip') + ' X',
            onClick: () => {
                const t = freeTransform.getTransform();
                freeTransform.setSize(-t.width, t.height);
            },
            css: actionBtnCss,
        });
        const flipYBtn = BB.el ({
            parent: buttonRow,
            tagName: 'button',
            content: LANG('filter-transform-flip') + ' Y',
            onClick: () => {
                const t = freeTransform.getTransform();
                freeTransform.setSize(t.width, -t.height);
            },
            css: actionBtnCss,
        });
        const scaleRotLeftBtn = BB.el ({
            parent: buttonRow,
            tagName: 'button',
            content: '-90°',
            onClick: () => {
                const t = freeTransform.getTransform();
                t.angleDeg -= 90;
                t.angleDeg %= 360;
                freeTransform.setAngleDeg(t.angleDeg);
                inputR.value = '' + Math.round(t.angleDeg);
                updatePreview();
            },
            css: actionBtnCss,
        });
        const scaleRotRightBtn = BB.el ({
            parent: buttonRow,
            tagName: 'button',
            content: '+90°',
            onClick: () => {
                const t = freeTransform.getTransform();
                t.angleDeg += 90;
                t.angleDeg %= 360;
                freeTransform.setAngleDeg(t.angleDeg);
                inputR.value = '' + Math.round(t.angleDeg);
                updatePreview();
            },
            css: actionBtnCss,
        });
        const scaleDoubleBtn = BB.el ({
            parent: buttonRow,
            tagName: 'button',
            content: '2x',
            onClick: () => {
                const t = freeTransform.getTransform();
                if (constrainCheckbox.getValue()) {
                    freeTransform.setSize(freeTransform.getRatio() * t.height * 2, t.height * 2);
                } else {
                    freeTransform.setSize(t.width * 2, t.height * 2);
                }
            },
            css: actionBtnCss,
        });
        const scaleHalfBtn = BB.el ({
            parent: buttonRow,
            tagName: 'button',
            content: '1/2x',
            onClick: () => {
                const t = freeTransform.getTransform();
                freeTransform.setSize(Math.round(t.width / 2), Math.round(t.height / 2));
            },
            css: actionBtnCss,
        });
        const centerBtn = BB.el ({
            parent: buttonRow,
            tagName: 'button',
            content: LANG('center'),
            onClick: () => {
                const t = freeTransform.getTransform();
                freeTransform.setPos({ x: context.canvas.width / 2, y: context.canvas.height / 2 });
                freeTransform.setAngleDeg(t.angleDeg);
                updatePreview();
            },
            css: actionBtnCss,
        });

        let isConstrained = true;
        const constrainCheckbox = new Checkbox({
            init: true,
            label: LANG('filter-transform-constrain'),
            title: LANG('constrain-proportions'),
            allowTab: true,
            callback: function (b) {
                isConstrained = b;
                freeTransform.setConstrained(isConstrained);
            },
            css: {
                display: 'inline-block',
            },
        });
        let isSnapping = false;
        const snappingCheckbox = new Checkbox({
            init: true,
            label: LANG('filter-transform-snap'),
            title: LANG('filter-transform-snap-title'),
            allowTab: true,
            callback: function (b) {
                isSnapping = b;
                freeTransform.setSnapping(isSnapping);
            },
            css: {
                display: 'inline-block',
                marginLeft: '10px',
            },
        });
        const checkboxWrapper = BB.el();
        checkboxWrapper.append(constrainCheckbox.getElement(), snappingCheckbox.getElement());

        div.append(BB.el({
            css: {
                clear: 'both',
                height: '10px',
            },
        }));

        const bottomRow = BB.el({
            parent: div,
            css: {
                display: 'flex',
                justifyContent: 'space-between',
                alignItems: 'center',
            },
        });

        const algorithmSelect = new Select({
            isFocusable: true,
            optionArr: [
                ['smooth', LANG('algorithm-smooth')],
                ['pixelated', LANG('algorithm-pixelated')],
            ],
            initValue: 'smooth',
            title: LANG('scaling-algorithm'),
            onChange: (): void => {
                updatePreview(true);
            },
        });
        bottomRow.append(checkboxWrapper, algorithmSelect.getElement());

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

        const previewLayerArr: IKlBasicLayer[] = [];
        {
            for (let i = 0; i < layers.length; i++) {
                let canvas;
                if (i === selectedLayerIndex) {
                    canvas = BB.canvas(parseInt('' + w), parseInt('' + h));
                    const ctx = BB.ctx(canvas);
                    ctx.drawImage(layers[i].context.canvas, 0, 0, canvas.width, canvas.height);
                } else {
                    canvas = layers[i].context.canvas;
                }
                previewLayerArr.push({
                    image: 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);

        let lastDrawnTransformStr = '';
        function updatePreview (doForce: boolean = false) {
            if (!freeTransform) {
                return;
            }
            const transform = freeTransform.getTransform();
            if (JSON.stringify(transform) === lastDrawnTransformStr && !doForce) {
                return;
            }
            lastDrawnTransformStr = JSON.stringify(transform);
            if (displayPreviewFactor < 1) {
                transform.x *= displayPreviewFactor;
                transform.y *= displayPreviewFactor;
                transform.width *= displayPreviewFactor;
                transform.height *= displayPreviewFactor;
            }
            const transformLayerCanvas = previewLayerArr[selectedLayerIndex].image as HTMLCanvasElement;
            const ctx = BB.ctx(transformLayerCanvas);
            ctx.save();
            ctx.clearRect(0, 0, transformLayerCanvas.width, transformLayerCanvas.height);
            BB.drawTransformedImageWithBounds(
                ctx,
                layers[selectedLayerIndex].context.canvas,
                transform,
                boundsObj,
                algorithmSelect.getValue() === 'pixelated' ||
                BB.testShouldPixelate(transform, transform.width / initTransform.width, transform.height / initTransform.height),
            );
            ctx.restore();
            klCanvasPreview.render();
        }

        const freeTransform = new FreeTransform({
            x: initTransform.x,
            y: initTransform.y,
            width: initTransform.width,
            height: initTransform.height,
            angleDeg: initTransform.angleDeg,
            isConstrained: true,
            snapX: [0, context.canvas.width],
            snapY: [0, context.canvas.height],
            callback: function (t) {
                inputX.value = '' + Math.round(t.x - initTransform.x);
                inputY.value = '' + Math.round(t.y - initTransform.y);
                inputR.value = '' + Math.round(t.angleDeg);
                updatePreview();
            },
            scale: displayPreviewFactor,
        });
        BB.css(freeTransform.getElement(), {
            position: 'absolute',
            left: '0',
            top: '0',
        });
        previewInnerWrapper.append(freeTransform.getElement());

        function onInputsChanged () {
            freeTransform.setPos({
                x: parseInt(inputX.value) + initTransform.x,
                y: parseInt(inputY.value) + initTransform.y}
            );
            freeTransform.setAngleDeg(parseInt(inputR.value));
            updatePreview();
        }

        updatePreview();

        div.append(previewWrapper);
        result.destroy = (): void => {
            keyListener.destroy();
            freeTransform.destroy();
            constrainCheckbox.destroy();
            snappingCheckbox.destroy();
            BB.destroyEl(flipXBtn);
            BB.destroyEl(flipYBtn);
            BB.destroyEl(scaleRotLeftBtn);
            BB.destroyEl(scaleRotRightBtn);
            BB.destroyEl(scaleDoubleBtn);
            BB.destroyEl(scaleHalfBtn);
            BB.destroyEl(centerBtn);
            klCanvasPreview.destroy();
        };
        result.getInput = function (): TFilterTransformInput {
            const transform = freeTransform.getTransform();
            const input: TFilterTransformInput = {
                transform,
                bounds: boundsObj,
                isPixelated: algorithmSelect.getValue() === 'pixelated' ||
                    BB.testShouldPixelate(transform, transform.width / initTransform.width, transform.height / initTransform.height),
            };
            result.destroy!();
            return BB.copyObj(input);
        };
        return result;
    },

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

        const input = params.input;

        const copyCanvas = BB.copyCanvas(context.canvas);
        context.clearRect(0, 0, context.canvas.width, context.canvas.height);
        BB.drawTransformedImageWithBounds(
            context,
            copyCanvas,
            input.transform,
            input.bounds,
            input.isPixelated,
        );

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

};


code:
import {BB} from '../../bb/bb';
import {eventResMs} from './filters-consts';
import {KlSlider} from '../ui/components/kl-slider';
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 TFilterUnsharpMaskInput = {
    radius: number;
    strength: number;
};

export type TFilterUnsharpMaskHistoryEntry = TFilterHistoryEntry<
    'unsharpMask',
    TFilterUnsharpMaskInput>;

export const filterUnsharpMask = {

    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 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<TFilterUnsharpMaskInput> = {
            element: div,
        };

        function finishInit () {
            let radius = 2, strength = 5.1 / 10;
            div.innerHTML = LANG('filter-unsharp-mask-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: 0,
                max: 200,
                value: 2,
                eventResMs: eventResMs,
                onChange: function (val) {
                    radius = val;
                    fxCanvas.draw(texture).unsharpMask(radius * previewFactor, strength).update();
                    klCanvasPreview.render();
                },
                curve: [[0, 0], [0.1, 2], [0.5, 50], [1, 200]],
            });
            const strengthSlider = new KlSlider({
                label: LANG('filter-unsharp-mask-strength'),
                width: 300,
                height: 30,
                min: 0,
                max: 50,
                value: 5.1,
                eventResMs: eventResMs,
                onChange: function (val) {
                    strength = val / 10;
                    fxCanvas.draw(texture).unsharpMask(radius * previewFactor, strength).update();
                    klCanvasPreview.render();
                },
                curve: [[0, 0], [0.1, 2], [0.5, 10], [1, 50]],
            });
            radiusSlider.getElement().style.marginBottom = '10px';
            div.append(radiusSlider.getElement());
            div.append(strengthSlider.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).unsharpMask(radius * previewFactor, strength).update();
                klCanvasPreview.render();
            } catch (e) {
                (div as any).errorCallback(e);
            }

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

        setTimeout(finishInit, 1);

        return result;
    },

    apply (params: IFilterApply<TFilterUnsharpMaskInput>): boolean {
        const context = params.context;
        const history = params.history;
        const radius = params.input.radius;
        const strength = params.input.strength;
        if (!context || radius === null || strength === null || !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).unsharpMask(radius, strength).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', 'unsharpMask'],
            action: 'apply',
            params: [{
                input: params.input,
            }],
        } as TFilterUnsharpMaskHistoryEntry);
        return true;
    },

};


code:
import {BB} from '../../bb/bb';
import {KlCanvasPreview} from '../canvas-ui/canvas-preview';
import {IFilterApply, IFilterGetDialogParam, IFilterGetDialogResult, IKlBasicLayer, IRGB} from '../kl-types';
import {LANG} from '../../language/language';
import {input} from '../ui/components/input';
import {ColorOptions} from '../ui/components/color-options';
import {drawVanishPoint} from '../image-operations/draw-vanish-point';
import {KlSlider} from '../ui/components/kl-slider';
import {eventResMs} from './filters-consts';
import {IVector2D} from '../../bb/bb-types';
import {TFilterHistoryEntry} from './filters';
import {throwIfNull} from '../../bb/base/base';

export type TFilterVanishPointInput = {
    x: number;
    y: number;
    lines: number;
    thickness: number;
    color: IRGB;
    opacity: number;
};

export type TFilterVanishPointHistoryEntry = TFilterHistoryEntry<
    'vanishPoint',
    TFilterVanishPointInput>;

export const filterVanishPoint = {

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

        const layers = klCanvas.getLayers();
        const selectedLayerIndex = throwIfNull(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 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 div = document.createElement('div');
        const result: IFilterGetDialogResult<TFilterVanishPointInput> = {
            element: div,
        };
        div.innerHTML = LANG('filter-vanish-point-description') + '

';

        const settingsObj: TFilterVanishPointInput = {
            x: context.canvas.width / 2,
            y: context.canvas.height / 2,
            lines: 8,
            thickness: 2,
            color: {r: 0, g: 0, b: 0},
            opacity: 1,
        };

        const linesSlider = new KlSlider({
            label: LANG('filter-vanish-point-lines'),
            width: 300,
            height: 30,
            min: 2,
            max: 20,
            value: settingsObj.lines,
            curve: 'quadratic',
            eventResMs: eventResMs,
            onChange: function (val) {
                settingsObj.lines = Math.round(val);
                updatePreview();
            },
        });
        linesSlider.getElement().style.marginBottom = '10px';
        div.append(linesSlider.getElement());

        const line1 = BB.el({
            parent: div,
            css: {
                display: 'flex',
                alignItems: 'center',
            },
        });
        const line2 = BB.el({
            parent: div,
            css: {
                display: 'flex',
                alignItems: 'center',
                marginTop: '10px',
            },
        });
        const xInput = input({
            init: settingsObj.x,
            type: 'number',
            css: {width: '75px', marginRight: '20px'},
            callback: function (v) {
                settingsObj.x = parseFloat(v);
                updatePreview();
            },
        });
        const yInput = input({
            init: settingsObj.y,
            type: 'number',
            css: {width: '75px', marginRight: '20px'},
            callback: function (v) {
                settingsObj.y = parseFloat(v);
                updatePreview();
            },
        });
        const thicknessInput = input({
            init: 2,
            type: 'number',
            min: 1,
            css: {width: '75px', marginRight: '20px'},
            callback: function (v) {
                settingsObj.thickness = parseFloat(v);
                updatePreview();
            },
        });

        let selectedRgbaObj = {r: 0, g: 0, b: 0, a: 1};
        const colorOptionsArr = [
            {r: 0, g: 0, b: 0, a: 1},
            {r: 255, g: 255, b: 255, 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,
        });
        settingsObj.color = BB.copyObj(selectedRgbaObj);

        const colorOptions = new ColorOptions({
            label: LANG('shape-stroke'),
            colorArr: colorOptionsArr,
            onChange: function (rgbaObj) {
                selectedRgbaObj = rgbaObj!;
                settingsObj.color = BB.copyObj(selectedRgbaObj);
                updatePreview();
            },
        });

        const labelStyle = {
            display: 'inline-block',
            marginRight: '5px',
        };
        line1.append(
            BB.el({content: 'X:', css: labelStyle}),
            xInput,
            BB.el({content: 'Y:', css: labelStyle}),
            yInput,
        );
        line2.append(
            BB.el({content: LANG('shape-line-width') + ':', css: labelStyle}),
            thicknessInput,
            BB.el({css:{flexGrow: '1'}}),
            colorOptions.getElement(),
        );

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

        const previewLayer: IKlBasicLayer = {
            image: BB.canvas(renderW, renderH),
            isVisible: layers[selectedLayerIndex].isVisible,
            opacity: layers[selectedLayerIndex].opacity,
            mixModeStr: layers[selectedLayerIndex].mixModeStr,
        };
        const klCanvasPreview = new KlCanvasPreview({
            width: Math.round(w),
            height: Math.round(h),
            layers: 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 previewInnerWrapper = BB.el({
            className: 'kl-preview-wrapper__canvas',
            css: {
                width: parseInt('' + w) + 'px',
                height: parseInt('' + h) + 'px',
                cursor: 'move',
            },
        });
        previewInnerWrapper.append(klCanvasPreview.getElement());
        previewWrapper.append(previewInnerWrapper);

        // ---- preview input processing ----
        const inputs = {} as {
            start: IVector2D;
            end: IVector2D | null;
            state: null | 'move';
            oldSettings: TFilterVanishPointInput;
        };
        function syncInputs (): void {
            xInput.value = '' + settingsObj.x;
            yInput.value = '' + settingsObj.y;
        }
        previewWrapper.oncontextmenu = function () {
            return false;
        };
        previewInnerWrapper.style.touchAction = 'none';
        const pointerListener = new BB.PointerListener({
            target: previewInnerWrapper,
            onPointer: (event) => {
                if (event.type === 'pointerdown') {
                    if (!inputs.state) {
                        inputs.state = 'move';
                        inputs.oldSettings = BB.copyObj(settingsObj);
                        inputs.start = {x: event.relX / previewFactor, y: event.relY / previewFactor};
                        inputs.end = null;
                    }
                } else if (event.type === 'pointermove') {
                    if (inputs.state) {
                        inputs.end = {x: event.relX / previewFactor, y: event.relY / previewFactor};

                        settingsObj.x = Math.round(inputs.end.x - inputs.start.x + inputs.oldSettings.x);
                        settingsObj.y = Math.round(inputs.end.y - inputs.start.y + inputs.oldSettings.y);

                        syncInputs();
                        updatePreview();
                    }
                } else if (event.type === 'pointerup') {
                    if (inputs.state) {
                        inputs.state = null;
                    }
                }
            },
        });

        function updatePreview (): void {
            const ctx = BB.ctx((previewLayer.image as HTMLCanvasElement));
            ctx.save();
            ctx.clearRect(0, 0, renderW, renderH);
            ctx.drawImage(context.canvas, 0, 0, renderW, renderH);
            drawVanishPoint(
                ctx,
                settingsObj.x * renderFactor,
                settingsObj.y * renderFactor,
                settingsObj.lines,
                Math.max(settingsObj.thickness * renderFactor, 1),
                settingsObj.color,
                settingsObj.opacity,
            );

            ctx.restore();
            klCanvasPreview.render();
        }
        setTimeout(updatePreview, 0);

        div.append(previewWrapper);
        result.destroy = () => {
            linesSlider.destroy();
            pointerListener.destroy();
            klCanvasPreview.destroy();
            colorOptions.destroy();
        };
        result.getInput = function (): TFilterVanishPointInput {
            result.destroy!();
            return BB.copyObj(settingsObj);
        };
        return result;
    },

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

        history.pause(true);
        drawVanishPoint(
            context,
            params.input.x,
            params.input.y,
            params.input.lines,
            params.input.thickness,
            params.input.color,
            params.input.opacity,
        );
        history.pause(false);

        history.push({
            tool: ['filter', 'vanishPoint'],
            action: 'apply',
            params: [{
                input: params.input,
            }],
        } as TFilterVanishPointHistoryEntry);
        return true;
    },

};


code: export const eventResMs = 60;


code:
import {filterLib, filterLibStatus} from './filters';
import {filterBrightnessContrast} from './filter-brightness-contrast';
import {filterCropExtend} from './filter-crop-extend';
import {filterCurves} from './filter-curves';
import {filterFlip} from './filter-flip';
import {filterHueSaturation} from './filter-hue-saturation';
import {filterInvert} from './filter-invert';
import {filterPerspective} from './filter-perspective';
import {filterResize} from './filter-resize';
import {filterRotate} from './filter-rotate';
import {filterTiltShift} from './filter-tilt-shift';
import {filterTransform} from './filter-transform';
import {filterBlur} from './filter-blur';
import {filterUnsharpMask} from './filter-unsharp-mask';
import {filterToAlpha} from './filter-to-alpha';
import {filterGrid} from './filter-grid';
import {filterNoise} from './filter-noise';
import {filterPattern} from './filter-pattern';
import {filterDistort} from './filter-distort';
import {filterVanishPoint} from './filter-vanish-point';
import {IFilter} from '../kl-types';

type TModuleFilter = Pick<IFilter, 'getDialog' | 'apply'>;

function importFilter (libObj: IFilter, moduleObj: TModuleFilter): void {
    if (moduleObj.getDialog) {
        libObj.getDialog = moduleObj.getDialog;
    }
    libObj.apply = moduleObj.apply;
}

export function importFilters (): void {
    if (filterLibStatus.isLoaded) {
        return;
    }
    importFilter(filterLib.brightnessContrast, filterBrightnessContrast as TModuleFilter);
    importFilter(filterLib.cropExtend, filterCropExtend as TModuleFilter);
    importFilter(filterLib.curves, filterCurves as TModuleFilter);
    importFilter(filterLib.flip, filterFlip as TModuleFilter);
    importFilter(filterLib.hueSaturation, filterHueSaturation as TModuleFilter);
    importFilter(filterLib.invert, filterInvert as TModuleFilter);
    importFilter(filterLib.perspective, filterPerspective as TModuleFilter);
    importFilter(filterLib.resize, filterResize as TModuleFilter);
    importFilter(filterLib.rotate, filterRotate as TModuleFilter);
    importFilter(filterLib.tiltShift, filterTiltShift as TModuleFilter);
    importFilter(filterLib.transform, filterTransform as TModuleFilter);
    importFilter(filterLib.blur, filterBlur as TModuleFilter);
    importFilter(filterLib.unsharpMask, filterUnsharpMask as TModuleFilter);
    importFilter(filterLib.toAlpha, filterToAlpha as TModuleFilter);
    importFilter(filterLib.grid, filterGrid as TModuleFilter);
    importFilter(filterLib.noise, filterNoise as TModuleFilter);
    importFilter(filterLib.pattern, filterPattern as TModuleFilter);
    importFilter(filterLib.distort, filterDistort as TModuleFilter);
    importFilter(filterLib.vanishPoint, filterVanishPoint as TModuleFilter);

    filterLibStatus.isLoaded = true;
}


code:
import {IFilter} from '../kl-types';
import glBrightnessContrastImg from '/src/app/img/ui/edit-brightness-contrast.svg';
import cropExtendImg from '/src/app/img/ui/edit-crop.svg';
import glCurvesImg from '/src/app/img/ui/edit-curves.svg';
import flipImg from '/src/app/img/ui/edit-flip.svg';
import glHueSaturationImg from '/src/app/img/ui/edit-hue-saturation.svg';
import invertImg from '/src/app/img/ui/edit-invert.png';
import glPerspectiveImg from '/src/app/img/ui/edit-perspective.svg';
import resizeImg from '/src/app/img/ui/edit-resize.svg';
import rotateImg from '/src/app/img/ui/edit-rotate.svg';
import glTiltShiftImg from '/src/app/img/ui/edit-tilt-shift.png';
import toAlphaImg from '/src/app/img/ui/edit-to-alpha.svg';
import transformImg from '/src/app/img/ui/edit-transform.svg';
import glBlurImg from '/src/app/img/ui/edit-triangle-blur.png';
import glUnsharpMaskImg from '/src/app/img/ui/edit-unsharp-mask.png';
import gridImg from '/src/app/img/ui/edit-grid.svg';
import noiseImg from '/src/app/img/ui/edit-noise.svg';
import patternImg from '/src/app/img/ui/edit-pattern.svg';
import vanishPointImg from '/src/app/img/ui/edit-vanish-point.svg';
import distortImg from '/src/app/img/ui/edit-distort.svg';
import {THistoryEntryG} from '../history/kl-history';

export type TFilterHistoryEntry<Filter extends string, Input> = THistoryEntryG<['filter', Filter], 'apply', [{ input: Input }]>;

export const filterLibStatus = {
    isLoaded: false,
};
export const filterLib: {
    [key:string]: IFilter;
} = {
    brightnessContrast: {
        lang: {name: 'filter-bright-contrast-title', button: 'filter-bright-contrast'},
        icon: glBrightnessContrastImg,
        updatePos: false,
        getDialog: null,
        apply: null,
        inEmbed: true,
        webGL: true,
    },
    cropExtend: {
        lang: {name: 'filter-crop-title', button: 'filter-crop-extend'},
        icon: cropExtendImg,
        updatePos: true,
        getDialog: null,
        apply: null,
        inEmbed: false,
    },
    curves: {
        lang: {name: 'filter-curves-title', button: 'filter-curves'},
        icon: glCurvesImg,
        updatePos: false,
        getDialog: null,
        apply: null,
        inEmbed: true,
        webGL: true,
    },
    distort: {
        lang: {name: 'filter-distort', button: 'filter-distort'},
        icon: distortImg,
        updatePos: false,
        getDialog: null,
        apply: null,
        inEmbed: true,
        webGL: true,
    },
    flip: {
        lang: {name: 'filter-flip-title', button: 'filter-flip'},
        icon: flipImg,
        updatePos: false,
        getDialog: null,
        apply: null,
        inEmbed: true,
    },
    hueSaturation: {
        lang: {name: 'filter-hue-sat-title', button: 'filter-hue-sat'},
        icon: glHueSaturationImg,
        updatePos: false,
        getDialog: null,
        apply: null,
        inEmbed: true,
        darkNoInvert: true,
        webGL: true,
    },
    invert: {
        lang: {name: 'filter-invert', button: 'filter-invert'},
        icon: invertImg,
        updatePos: false,
        isInstant: true,
        getDialog: null,
        apply: null,
        inEmbed: true,
        darkNoInvert: true,
        webGL: true,
    },
    perspective: {
        lang: {name: 'filter-perspective-title', button: 'filter-perspective'},
        icon: glPerspectiveImg,
        updatePos: false,
        getDialog: null,
        apply: null,
        inEmbed: true,
        webGL: true,
    },
    resize: {
        lang: {name: 'filter-resize-title', button: 'filter-resize'},
        icon: resizeImg,
        updatePos: true,
        getDialog: null,
        apply: null,
        inEmbed: false,
    },
    rotate: {
        lang: {name: 'filter-rotate-title', button: 'filter-rotate'},
        icon: rotateImg,
        updatePos: true,
        getDialog: null,
        apply: null,
        inEmbed: false,
    },
    tiltShift: {
        lang: {name: 'filter-tilt-shift-title', button: 'filter-tilt-shift'},
        icon: glTiltShiftImg,
        updatePos: false,
        getDialog: null,
        apply: null,
        inEmbed: true,
        webGL: true,
    },
    toAlpha: {
        lang: {name: 'filter-to-alpha-title', button: 'filter-to-alpha'},
        icon: toAlphaImg,
        updatePos: false,
        getDialog: null,
        apply: null,
        inEmbed: true,
        darkNoInvert: true,
        webGL: true,
    },
    transform: {
        lang: {name: 'filter-transform-title', button: 'filter-transform'},
        icon: transformImg,
        updatePos: false,
        getDialog: null,
        apply: null,
        inEmbed: true,
    },
    blur: {
        lang: {name: 'filter-triangle-blur-title', button: 'filter-triangle-blur'},
        icon: glBlurImg,
        updatePos: false,
        getDialog: null,
        apply: null,
        inEmbed: true,
        darkNoInvert: true,
        webGL: true,
    },
    unsharpMask: {
        lang: {name: 'filter-unsharp-mask-title', button: 'filter-unsharp-mask'},
        icon: glUnsharpMaskImg,
        updatePos: false,
        getDialog: null,
        apply: null,
        inEmbed: true,
        darkNoInvert: true,
        webGL: true,
    },
    grid: {
        lang: {name: 'filter-grid', button: 'filter-grid'},
        icon: gridImg,
        updatePos: false,
        getDialog: null,
        apply: null,
        inEmbed: true,
    },
    noise: {
        lang: {name: 'filter-noise', button: 'filter-noise'},
        icon: noiseImg,
        updatePos: false,
        getDialog: null,
        apply: null,
        inEmbed: true,
        webGL: true,
    },
    pattern: {
        lang: {name: 'filter-pattern', button: 'filter-pattern'},
        icon: patternImg,
        updatePos: false,
        getDialog: null,
        apply: null,
        inEmbed: true,
    },
    vanishPoint: {
        lang: {name: 'filter-vanish-point-title', button: 'filter-vanish-point'},
        icon: vanishPointImg,
        updatePos: false,
        getDialog: null,
        apply: null,
        inEmbed: true,
    },
};


code:
// --------- history entry types ------------------------------
import {throwIfNull} from '../../bb/base/base';

export interface IHistoryAction {
    action: string;
    params: any[];
}

export interface IHistoryEntry {
    tool: string[];
    action?: string;
    params?: any[];
    actions?: IHistoryAction[];
}

type THistoryActionParams<T> = T extends (...args: infer A) => void ? A : never;
export type THistoryActions<ToolName, T> = {
    [K in keyof T]: {
        tool: [ToolName];
        action: K;
        params: THistoryActionParams<T[K]>;
    }
}[keyof T];

export type THistoryInnerActions<T> = {
    [K in keyof T]: {
        action: K;
        params: THistoryActionParams<T[K]>;
    }
}[keyof T];

export type THistoryEntryG<Tool extends string[], Action extends string, Params extends unknown[]> = {
    tool: Tool;
    action: Action;
    params: Params;
}

export type TMiscImportImageHistoryEntry = THistoryEntryG<
    ['misc'],
    'importImage',
    [HTMLCanvasElement, string | undefined]>;

export type TMiscFocusLayerHistoryEntry = THistoryEntryG<
    ['misc'],
    'focusLayer',
    [number]>;

// --------- classes etc ------------------------------

export interface IHistoryBroadcast {
    bufferUpdate: IHistoryEntry;
}

let historyInstance: boolean = false;

export type IHistoryListener = (p: IHistoryBroadcast | null) => void;

export interface KlHistoryInterface {
    pause (b: boolean): void;
    addListener (l: IHistoryListener): void;
    push (e: IHistoryEntry): void;
    undo (): IHistoryEntry[];
    redo (): IHistoryEntry | undefined;
    getAll (): (IHistoryEntry | null)[];
    canRedo (): boolean;
    canUndo (): boolean;
    getState (): number;
    getActionNumber (): number;
}

export class DecoyKlHistory implements KlHistoryInterface {
    pause (): void {}
    addListener (): void {}
    push (): void {}
    undo (): IHistoryEntry[] { return []; }
    redo (): (IHistoryEntry | undefined) { return undefined; }
    getAll (): (IHistoryEntry | null)[] { return []; }
    canRedo (): boolean { return false; }
    canUndo (): boolean { return false; }
    getState (): number { return 0; }
    getActionNumber (): number { return 0; }
}

const DO_DELETE_OLD = true;

export class KlHistory implements KlHistoryInterface {

    private state: number; // on .push state increments by one
    private readonly entries: (IHistoryEntry | null)[]; // null if beyond max undo to trigger garbage collection
    private readonly listeners: IHistoryListener[];
    private pauseStack: number; // how often paused without unpause
    private readonly max: number = 20; // max number undo steps
    private maxState: number; // can't go backwards -> max state is the buffer image(klCanvas)
    private actionNumber: number; // current action the user is on. untouched document = -1 because dataArr.length is 0

    private broadcast (p: IHistoryBroadcast | null): void {
        setTimeout(() => {
            for (let i = 0; i < this.listeners.length; i++) {
                this.listeners[i](p);
            }
            this.state++;
        }, 1);
    }

    // ---- public ----

    constructor () {
        if (historyInstance) {
            throw new Error('klHistory already instantiated');
        }
        historyInstance = true;
        this.state = 0;
        this.entries = [];
        this.listeners = [];
        this.pauseStack = 0;
        this.maxState = -1;
        this.actionNumber = -1;
    }

    /**
     * Needed, because sometimes there are actions that would cause other undo steps.
     * For example a filter that does something with two layers and then merges them.
     * That should be a single undo step, and prevent merging from creating its own undo step.
     * Pause prevents creation of unintended undo steps.
     */
    pause (b: boolean): void {
        if (b) {
            this.pauseStack++;
        } else {
            this.pauseStack = Math.max(0, this.pauseStack - 1);
        }
    }

    addListener (l: IHistoryListener): void {
        this.listeners.push(l);
    }

    push (newEntry: IHistoryEntry): void {
        if (this.pauseStack > 0) {
            return;
        }
        while (this.actionNumber < this.entries.length - 1) {
            this.entries.pop();
        }

        //taking care of actions that shouldn't cause a new undo step
        const lastEntry = this.entries[this.entries.length - 1];
        // opacity
        if (
            lastEntry && newEntry.tool[0] === 'canvas' && lastEntry.tool[0] === 'canvas' &&
            newEntry.action === 'layerOpacity' && lastEntry.action === 'layerOpacity' &&
            lastEntry.params![0] === newEntry.params![0]
        ) {
            this.entries[this.entries.length - 1] = newEntry;
            this.state++; //still needs to increment because something changed
            return;
        }
        // layer visibility
        if (
            lastEntry && newEntry.tool[0] === 'canvas' && lastEntry.tool[0] === 'canvas' &&
            newEntry.action === 'setLayerIsVisible' && lastEntry.action === 'setLayerIsVisible' &&
            lastEntry.params![0] === newEntry.params![0] &&
            lastEntry.params![1] != newEntry.params![1]
        ) {
            // if last and current step toggle visibility -> that means it's the way it was before
            this.entries.pop();
            this.actionNumber--;
            this.state++; //still needs to increment because something changed
            this.broadcast(null);
            return;
        }
        // select layer
        if (newEntry.action === 'focusLayer' && lastEntry && lastEntry.action === 'focusLayer') {
            this.entries[this.entries.length - 1] = newEntry;
            this.state++;
            return;
        }

        this.entries[this.entries.length] = newEntry;
        this.actionNumber = this.entries.length - 1;
        const maxBefore = this.maxState;
        this.maxState = Math.max(this.maxState, this.actionNumber - this.max);
        if (maxBefore < this.maxState) {
            const item = this.entries[this.maxState];
            if (!item) {
                throw new Error('this.dataArr[this.maxState] null');
            }
            this.broadcast({bufferUpdate: item});
        } else {
            this.broadcast(null);
        }
        if (DO_DELETE_OLD && this.maxState >= 0) {
            this.entries[this.maxState] = null; //to free some memory...imported images take a lot of space
        }
    }

    /**
     * lowers action number, returns all entries that need to be executed
     * on trailing snapshot to represent the canvas one undo step back.
     * Empty array if it can't undo
     */
    undo (): IHistoryEntry[] {
        const result: IHistoryEntry[] = [];
        if (!this.canUndo()) {
            return result;
        }
        for (let i = this.maxState + 1; i < this.actionNumber; i++) {
            result.push(throwIfNull(this.entries[i]));
        }
        this.actionNumber--;
        this.broadcast(null);
        return result;
    }

    /**
     * raises action number, returns the action to be redone
     */
    redo (): (IHistoryEntry | undefined) {
        if (!this.canRedo()) {
            return undefined;
        }
        this.actionNumber++;
        this.broadcast(null);
        return throwIfNull(this.entries[this.actionNumber]);
    }

    getAll (): (IHistoryEntry | null)[] {
        return [...this.entries];
    }

    canRedo (): boolean {
        return this.actionNumber < this.entries.length - 1;
    }

    canUndo (): boolean {
        return this.actionNumber > this.maxState;
    }

    getState (): number {
        return this.state;
    }

    /**
     * actionNumber - number of undo-able actions a user has done (e.g. if drawn 5 lines total -> 5)
     */
    getActionNumber (): number {
        return (this.actionNumber + 1);
    }

}

export const klHistory = new KlHistory();


code:
import {KL} from '../kl';
import {IHistoryBroadcast, IHistoryEntry, klHistory} from './kl-history';
import {KlCanvasWorkspace} from '../canvas-ui/kl-canvas-workspace';
import {IFilter, IFilterApply, IInitState} from '../kl-types';
import {KlCanvas, KlCanvasContext} from '../canvas/kl-canvas';
import {LayerManager} from '../ui/tool-tabs/layer-manager/layer-manager';
import {LayerPreview} from '../ui/components/layer-preview';
import {HandUi} from '../ui/tool-tabs/hand-ui';
import {throwIfNull} from '../../bb/base/base';
import {TBrush} from '../brushes/brushes';

function execHistoryEntry (
    historyEntry: IHistoryEntry,
    layerIndex: number,
    klCanvas: KlCanvas,
    brushes: TBrush[],
    setCurrentLayerCtx: (ctx: CanvasRenderingContext2D) => void,
    getCurrentLayerCtx: () => CanvasRenderingContext2D,
): number {
    if (historyEntry.tool[0] === 'brush') {
        const b = brushes[historyEntry.tool[1] as any];
        historyEntry.actions!.forEach(action => {
            (b[action.action as keyof TBrush] as any)(...action.params);
        });

    } else if (historyEntry.tool[0] === 'canvas') {
        const p = historyEntry.params!;
        const id = (klCanvas[historyEntry.action as keyof KlCanvas] as any)(...p);
        if (typeof id === 'number') {
            layerIndex = id;
            const ctx = throwIfNull(klCanvas.getLayerContext(layerIndex)) as KlCanvasContext;
            setCurrentLayerCtx(ctx);
            Object.entries(brushes).forEach(([, brush]) => brush.setContext(ctx));
        }

    } else if (historyEntry.tool[0] === 'filter') {
        const p = [{
            context: getCurrentLayerCtx(),
            klCanvas,
            input: historyEntry.params![0].input,
            history: new KL.DecoyKlHistory(),
        } as IFilterApply];
        (KL.filterLib[historyEntry.tool[1]][historyEntry.action! as keyof IFilter] as any)(...p);

    } else if (historyEntry.tool[0] === 'misc' && historyEntry.action === 'focusLayer') {
        layerIndex = historyEntry.params![0];
        const ctx = throwIfNull(klCanvas.getLayerContext(layerIndex)) as KlCanvasContext;
        setCurrentLayerCtx(ctx);
        Object.entries(brushes).forEach(([, brush]) => brush.setContext(ctx));

    } else if (historyEntry.tool[0] === 'misc' && historyEntry.action === 'importImage') {
        const id = klCanvas.addLayer();
        if (typeof id === 'number') {
            layerIndex = id;
            if (historyEntry.params![1]) {
                klCanvas.renameLayer(layerIndex, historyEntry.params![1]);
            }
            const ctx = throwIfNull(klCanvas.getLayerContext(layerIndex)) as KlCanvasContext;
            setCurrentLayerCtx(ctx);
            Object.entries(brushes).forEach(([, brush]) => brush.setContext(ctx));
        }
        getCurrentLayerCtx().drawImage(historyEntry.params![0], 0, 0);
    }
    return layerIndex;
}

export class UndoRedoCatchup {

    private doIgnore = false;

    /**
     * Prevent multiple undo / redo getting triggered at once if UI is frozen.
     * true -> ignore, don't undo / redo
     */
    private shouldIgnoreTest (): boolean {
        if (this.doIgnore) {
            return true;
        }
        this.doIgnore = true;
        setTimeout(() => {
            this.doIgnore = false;
        }, 0);
        return false;
    }

    // ---- public ----

    constructor (
        private brushUiMap: any, // todo
        private layerPreview: LayerPreview,
        private layerManager: LayerManager,
        private handUi: HandUi,
        private klCanvasWorkspace: KlCanvasWorkspace,
        private getInitState: () => IInitState,
        private getKlCanvas: () => KlCanvas,
        private getCurrentLayerCtx: () => CanvasRenderingContext2D | null,
        private setCurrentLayerCtx: (ctx: CanvasRenderingContext2D) => void,
        private getCurrentBrush: () => TBrush,
    ) { }

    undo (): boolean {
        if (this.shouldIgnoreTest() || !klHistory.canUndo()) {
            return false;
        }
        const entries: IHistoryEntry[] = klHistory.undo();
        const klCanvas = this.getKlCanvas();
        klHistory.pause(true);
        const initState: IInitState = this.getInitState();
        const oldSize = {w: klCanvas.getWidth(), h: klCanvas.getHeight()};
        klCanvas.copy(initState.canvas);
        let layerIndex = initState.focus;
        this.setCurrentLayerCtx(throwIfNull(klCanvas.getLayerContext(layerIndex)));
        const brushes: any = {};
        Object.entries(KL.brushes).forEach(([b, brush]) => {
            brushes[b] = new brush();
            brushes[b].setContext(this.getCurrentLayerCtx());
        });
        brushes.SketchyBrush.setSeed(initState.brushes.SketchyBrush.getSeed());
        entries.forEach(entry => {
            layerIndex = execHistoryEntry(
                entry,
                layerIndex,
                klCanvas,
                brushes,
                (ctx) => this.setCurrentLayerCtx(ctx),
                () => throwIfNull(this.getCurrentLayerCtx()),
            );
        });
        if (oldSize.w !== klCanvas.getWidth() || oldSize.h !== klCanvas.getHeight()) {
            this.klCanvasWorkspace.resetOrFitView();
            this.handUi.update(this.klCanvasWorkspace.getScale(), this.klCanvasWorkspace.getAngleDeg());
        }
        this.layerManager.update(layerIndex);
        this.layerPreview.setLayer(throwIfNull(klCanvas.getLayer(layerIndex)));
        this.brushUiMap.sketchyBrush.setSeed(brushes.SketchyBrush.getSeed());
        this.getCurrentBrush().setContext(this.getCurrentLayerCtx()! as KlCanvasContext);
        this.klCanvasWorkspace.setLastDrawEvent();

        klHistory.pause(false);
        return true;
    }

    redo (): boolean {
        if (this.shouldIgnoreTest() || !klHistory.canRedo()) {
            return false;
        }
        const entry = klHistory.redo();
        if (!entry) {
            setTimeout(() => {
                throw new Error('redo failed. redo entry undefined');
            });
            return false;
        }
        const klCanvas = this.getKlCanvas();
        klHistory.pause(true);
        const oldSize = {w: klCanvas.getWidth(), h: klCanvas.getHeight()};
        const brushes: any = {};
        Object.entries(KL.brushes).forEach(([b, brush]) => {
            brushes[b] = new brush();
            brushes[b].setContext(this.getCurrentLayerCtx());
        });
        brushes.SketchyBrush.setSeed(this.brushUiMap.sketchyBrush.getSeed());
        execHistoryEntry(
            entry,
            0, // doesn't matter, because result not used
            klCanvas,
            brushes,
            (ctx) => this.setCurrentLayerCtx(ctx),
            () => throwIfNull(this.getCurrentLayerCtx()),
        );

        if (oldSize.w !== klCanvas.getWidth() || oldSize.h !== klCanvas.getHeight()) {
            this.klCanvasWorkspace.resetOrFitView();
            this.handUi.update(this.klCanvasWorkspace.getScale(), this.klCanvasWorkspace.getAngleDeg());
        }
        const currentLayerIndex = throwIfNull(klCanvas.getLayerIndex(this.getCurrentLayerCtx()!.canvas));
        this.layerManager.update(currentLayerIndex);
        this.layerPreview.setLayer(klCanvas.getLayer(currentLayerIndex)!);
        this.brushUiMap.sketchyBrush.setSeed(brushes.SketchyBrush.getSeed());
        this.getCurrentBrush().setContext(this.getCurrentLayerCtx()! as KlCanvasContext);
        this.klCanvasWorkspace.setLastDrawEvent();
        klHistory.pause(false);

        return true;
    }

    catchup (logParam: IHistoryBroadcast | null): void {
        // const start = performance.now();
        //play catch up (the version that is a few steps behind)

        if (!logParam || !logParam.bufferUpdate) {
            return;
        }

        const initState: IInitState = this.getInitState();
        let localCurrentLayerCtx = initState.canvas.getLayerContext(initState.focus);

        initState.focus = execHistoryEntry(
            logParam.bufferUpdate,
            initState.focus,
            initState.canvas,
            initState.brushes,
            (ctx) => {
                localCurrentLayerCtx = ctx;
            },
            () => throwIfNull(localCurrentLayerCtx),
        );

        // console.log('catchup', performance.now() - start);
    }

}


code:
// ideas: padding, gutter, other shapes

export function drawGrid (
    ctx: CanvasRenderingContext2D,
    cellsX: number,
    cellsY: number,
    thickness: number, // px
    color: string,
    opacity: number, // 0 - 1
): void {
    ctx.save();
    const w = ctx.canvas.width;
    const h = ctx.canvas.height;
    thickness = Math.round(thickness);
    const thickIsRound = thickness % 2 === 0;

    ctx.beginPath();
    ctx.lineWidth = thickness;
    ctx.strokeStyle = color;
    ctx.globalAlpha = opacity;

    for(let i = 0; i < cellsX - 1; i++) {
        const cw = w / (cellsX);
        let pos = cw * (i + 1);
        if (thickIsRound) {
            pos = Math.round(pos);
        } else {
            pos = Math.round(pos + 0.5) - 0.5;
        }
        ctx.moveTo(pos, 0);
        ctx.lineTo(pos, h);

    }
    for(let i = 0; i < cellsY - 1; i++) {
        const ch = h / (cellsY);
        let pos = ch * (i + 1);
        if (thickIsRound) {
            pos = Math.round(pos);
        } else {
            pos = Math.round(pos + 0.5) - 0.5;
        }

        ctx.moveTo(0, pos);
        ctx.lineTo(w, pos);
    }

    ctx.stroke();
    ctx.restore();
}


code:
import {drawShape} from './shape-tool';
import {BB} from '../../bb/bb';
import {IRGB} from '../kl-types';

export function drawVanishPoint (
    ctx: CanvasRenderingContext2D,
    x: number,
    y: number,
    lines: number, // int, [2, inf]
    thickness: number, // px
    color: IRGB,
    opacity: number, // 0 - 1
): void {
    ctx.save();

    const degIncrement = 180 / lines;
    for (let deg = 0; deg < 180; deg += degIncrement) {
        const p2 = BB.rotateAround({x, y}, {x: x + 9999, y}, deg);
        drawShape(
            ctx,
            {
                type: 'line',
                x1: x,
                y1: y,
                x2: p2.x,
                y2: p2.y,
                //angleRad: 0,
                isOutwards: true,
                opacity,
                //isEraser: false,
                strokeRgb: color,
                lineWidth: thickness,
                //doLockAlpha: false,
            }
        );
    }

    ctx.restore();
}


code:
/**
 * Flood fill. Tried https://github.com/binarymax/floodfill.js/, but it implemented tolerance wrong, and had bugs.
 * So, my own implementation. can handle tolerance, grow, opacity.
 * Needs to be optimized.
 */

/**
 * Set values in data within rect to 254, unless they're 255
 *
 * @param data Uint8Array
 * @param width int
 * @param x0 int
 * @param y0 int
 * @param x1 int >x0
 * @param y1 int >y0
 */
function fillRect (data: Uint8Array, width: number, x0: number, y0: number, x1: number, y1: number): void {
    for (let x = x0; x <= x1; x++) {
        for (let y = y0; y <= y1; y++) {
            if (data[y * width + x] === 255) {
                continue;
            }
            data[y * width + x] = 254;
        }
    }
}

let mx, my;

/**
 * Get index i moved by dX, dY. in array with dimensions width height.
 * Returns null if outside bounds.
 *
 * @param width int
 * @param height int
 * @param i int
 * @param dX int
 * @param dY int
 */
function moveIndex (width: number, height: number, i: number, dX: number, dY: number): (undefined | number) {
    mx = i % width + dX;
    my = Math.floor(i / width) + dY;

    if (mx < 0 || my < 0 || mx >= width || my >= height) {
        return undefined;
    }

    return my * width + mx;
}

/**
 * If pixel can be filled (within tolerance) will be set 255 and returns true.
 * returns false if already filled, or i is null
 *
 * @param srcArr Uint8ClampedArray rgba
 * @param targetArr Uint8Array
 * @param width int
 * @param height int
 * @param initRgba rgba
 * @param tolerance int 0 - 255
 * @param i int - srcArr index
 * @returns {boolean}
 */
function testAndFill (
    srcArr: Uint8ClampedArray,
    targetArr: Uint8Array,
    width: number,
    height: number,
    initRgba: [number, number, number, number],
    tolerance: number,
    i: number | undefined,
): boolean {
    if (i === undefined || targetArr[i] === 255) {
        return false;
    }

    if (
        srcArr[i * 4] === initRgba[0] &&
        srcArr[i * 4 + 1] === initRgba[1] &&
        srcArr[i * 4 + 2] === initRgba[2] &&
        srcArr[i * 4 + 3] === initRgba[3]
    ) {
        targetArr[i] = 255;
        return true;
    }

    if (
        tolerance > 0 &&
        Math.abs(srcArr[i * 4] - initRgba[0]) <= tolerance &&
        Math.abs(srcArr[i * 4 + 1] - initRgba[1]) <= tolerance &&
        Math.abs(srcArr[i * 4 + 2] - initRgba[2]) <= tolerance &&
        Math.abs(srcArr[i * 4 + 3] - initRgba[3]) <= tolerance
    ) {
        targetArr[i] = 255;
        return true;
    }

    return false;
}

/**
 *
 * @param srcArr Uint8ClampedArray rgba
 * @param targetArr Uint8Array
 * @param width int
 * @param height int
 * @param px int
 * @param py int
 * @param tolerance int 0 - 255
 * @param grow int >= 0
 * @param isContiguous boolean
 */
function floodFill (
    srcArr: Uint8ClampedArray,
    targetArr: Uint8Array,
    width: number,
    height: number,
    px: number,
    py: number,
    tolerance: number,
    grow: number,
    isContiguous: boolean,
): void {

    const initRgba: [number, number, number, number] = [
        srcArr[(py * width + px) * 4],
        srcArr[(py * width + px) * 4 + 1],
        srcArr[(py * width + px) * 4 + 2],
        srcArr[(py * width + px) * 4 + 3],
    ];

    if (isContiguous) {
        const q: number[] = [];
        q.push(py * width + px);
        targetArr[py * width + px] = 255;

        let i: number, e: number | undefined;
        while (q.length) {
            i = q.pop() as number; // a test would slow down the code

            // queue up unfilled neighbors
            e = moveIndex(width, height, i, -1, 0); // left
            // test will return false if e is undefined -> only numbers will be pushed to q
            testAndFill(srcArr, targetArr, width, height, initRgba, tolerance, e) && q.push(e as number);

            e = moveIndex(width, height, i, 1, 0); // right
            testAndFill(srcArr, targetArr, width, height, initRgba, tolerance, e) && q.push(e as number);

            e = moveIndex(width, height, i, 0, -1); // up
            testAndFill(srcArr, targetArr, width, height, initRgba, tolerance, e) && q.push(e as number);

            e = moveIndex(width, height, i, 0, 1); // bottom
            testAndFill(srcArr, targetArr, width, height, initRgba, tolerance, e) && q.push(e as number);
        }
    } else {
        for (let i = 0; i < width * height; i++) {
            testAndFill(srcArr, targetArr, width, height, initRgba, tolerance, i);
        }
    }

    // grow
    if (grow === 0) {
        return;
    }

    // how does it grow? it finds all pixel at the edge.
    // then depending on what kind of edge it is, it draws a rectangle into target
    // the rectangle has the value 254, or else it will mess it all up.
    // after it's all done, replaces it with 255
    let x0, x1, y0, y1;
    let l, tl, t, tr, r, br, b, bl; // left, top left, top, top right, etc.
    for (let x = 0; x < width; x++) {
        for (let y = 0; y < height; y++) {
            if (targetArr[y * width + x] !== 255) {
                continue;
            }

            // bounds of rectangle
            x0 = x;
            x1 = x;
            y0 = y;
            y1 = y;

            l = targetArr[(y) * width + x - 1] !== 255;
            tl = targetArr[(y - 1) * width + x - 1] !== 255;
            t = targetArr[(y - 1) * width + x] !== 255;
            tr = targetArr[(y - 1) * width + x + 1] !== 255;
            r = targetArr[(y) * width + x + 1] !== 255;
            br = targetArr[(y + 1) * width + x + 1] !== 255;
            b = targetArr[(y + 1) * width + x] !== 255;
            bl = targetArr[(y + 1) * width + x - 1] !== 255;

            if (l) { // left
                x0 = x - grow;
            }
            if (l && tl && t) { // top left
                x0 = x - grow;
                y0 = y - grow;
            }
            if (t) { // top
                y0 = Math.min(y0, y - grow);
            }
            if (t && tr && r) { // top right
                y0 = Math.min(y0, y - grow);
                x1 = x + grow;
            }
            if (r) { // right
                x1 = Math.max(x1, x + grow);
            }
            if (r && br && b) { // bottom right
                x1 = Math.max(x1, x + grow);
                y1 = Math.max(y1, y + grow);
            }
            if (b) { // bottom
                y1 = Math.max(y1, y + grow);
            }
            if (b && bl && l) { // bottom left
                x0 = Math.min(x0, x - grow);
                y1 = Math.max(y1, y + grow);
            }

            if (!l && !tl && !t && !tr && !r && !br && !b && !bl) {
                continue;
            }

            fillRect(
                targetArr,
                width,
                Math.max(0, x0),
                Math.max(0, y0),
                Math.min(width - 1, x1),
                Math.min(height - 1, y1)
            );
        }
    }
    for (let i = 0; i < width * height; i++) {
        if (targetArr[i] === 254) {
            targetArr[i] = 255;
        }
    }

}

/**
 * Does flood fill, and returns that. an array - 0 not filled. 255 filled

 * @param rgbaArr Uint8ClampedArray rgba
 * @param width int
 * @param height int
 * @param x int
 * @param y int
 * @param tolerance int 0 - 255
 * @param grow int >= 0
 * @param isContiguous boolean
 */
export function floodFillBits (
    rgbaArr: Uint8ClampedArray,
    width: number,
    height: number,
    x: number,
    y: number,
    tolerance: number,
    grow: number,
    isContiguous: boolean,
): {
    data: Uint8Array;
} {
    x = Math.round(x);
    y = Math.round(y);

    const resultArr = new Uint8Array(new ArrayBuffer(width * height));

    floodFill(rgbaArr, resultArr, width, height, x, y, tolerance, grow, isContiguous);

    return {
        data: resultArr,
    };
}


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

type TOnGradient = (isDone: boolean, x1: number, y1: number, x2: number, y2: number, angleRad: number) => void;

/**
 * Input processor for gradient tool.
 * Coordinates are in canvas space.
 * angleRad is the angle of the canvas.
 */
export class GradientTool {
    private downX: number = 0;
    private downY: number = 0;
    private downAngleRad: number = 0;
    private readonly onGradient: TOnGradient;

    // ---- public ----
    constructor (
        p: {
            onGradient: TOnGradient;
        }
    ) {
        this.onGradient = p.onGradient;
    }

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

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

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

export function drawGradient (
    ctx: CanvasRenderingContext2D,
    gradientObj: IGradient,
): void {
    ctx.save();

    const x1 = gradientObj.x1;
    const y1 = gradientObj.y1;
    let x2 = gradientObj.x2;
    let y2 = gradientObj.y2;

    if (gradientObj.doSnap) {
        const angleDeg = gradientObj.angleRad * 180 / Math.PI;

        const r1 = BB.rotate(x1, y1, gradientObj.angleRad / Math.PI * 180);
        const r2 = BB.rotate(x2, y2, gradientObj.angleRad / Math.PI * 180);

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

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

    let baseColor = gradientObj.color1;
    if (gradientObj.isEraser && gradientObj.doLockAlpha) {
        baseColor = {r: 255, g: 255, b: 255};
    }
    let color1: IRGBA = {
        ...baseColor,
        a: gradientObj.opacity,
    };
    let color2: IRGBA = {
        ...baseColor,
        a: 0,
    };
    if (gradientObj.isReversed) {
        const temp = color1;
        color1 = color2;
        color2 = temp;
    }

    let gradient: CanvasGradient;
    if (gradientObj.type === 'linear') {
        gradient = ctx.createLinearGradient(x1, y1, x2, y2);
        gradient.addColorStop(0, BB.ColorConverter.toRgbaStr(color1));
        gradient.addColorStop(1, BB.ColorConverter.toRgbaStr(color2));

    } else if (gradientObj.type === 'linear-mirror') {
        const d = {
            x: x2 - x1,
            y: y2 - y1,
        };
        gradient = ctx.createLinearGradient(x1 - d.x,y1 - d.y, x2, y2);
        gradient.addColorStop(0, BB.ColorConverter.toRgbaStr(color2));
        gradient.addColorStop(0.5, BB.ColorConverter.toRgbaStr(color1));
        gradient.addColorStop(1, BB.ColorConverter.toRgbaStr(color2));

    } else if (gradientObj.type === 'radial') {
        const r = BB.Vec2.dist(
            {x: x1, y: y1},
            {x: x2, y: y2},
        );
        gradient = ctx.createRadialGradient(x1, y1, 0, x1, y1, r);
        gradient.addColorStop(0, BB.ColorConverter.toRgbaStr(color1));
        gradient.addColorStop(1, BB.ColorConverter.toRgbaStr(color2));
    }

    ctx.fillStyle = gradient!;
    if (gradientObj.isEraser) {
        ctx.globalCompositeOperation = 'destination-out';
    }
    if (gradientObj.doLockAlpha) {
        ctx.globalCompositeOperation = 'source-atop';
    }
    ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);

    ctx.restore();
}


code:
import {BB} from '../../bb/bb';
import {IBounds, IRect} from '../../bb/bb-types';
import {IRGBA} from '../kl-types';

export type TTextFormat = 'left' | 'center' | 'right';
export type TTextFont = 'serif' | 'monospace' | 'sans-serif' | 'cursive' | 'fantasy' | string;

export type TRenderTextParam = {
    text: string; // text to be drawn. can contain newlines

    x: number;
    y: number;
    angleRad: number;
    size: number; // px
    align: TTextFormat;
    isBold: boolean;
    isItalic: boolean;
    font: TTextFont;
    letterSpacing?: number;
    lineHeight?: number; // em
    fill?: {
        color: IRGBA;
    };
    stroke?: {
        color: IRGBA;
        lineWidth: number;
    };
}

function textMetricToRect (metrics: TextMetrics, align: TTextFormat): IRect {
    if (align === 'left') {
        return {
            x: 0,
            y: -metrics.fontBoundingBoxAscent,
            width: metrics.width,
            height: metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent,
        };
    }
    if (align === 'right') {
        return {
            x: -metrics.width,
            y: -metrics.fontBoundingBoxAscent,
            width: metrics.width,
            height: metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent,
        };
    }
    // center
    return {
        x: -metrics.width / 2,
        y: -metrics.fontBoundingBoxAscent,
        width: metrics.width,
        height: metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent,
    };
}

/**
 * Draws text on a canvas.
 * Return bounds, relative to p.x, p.y.
 *
 * @param canvas
 * @param p
 */
export function renderText (canvas: HTMLCanvasElement, p: TRenderTextParam): IRect {

    p = BB.copyObj(p);

    // setup context
    const ctx = BB.ctx(canvas) as CanvasRenderingContext2D & {letterSpacing: string};
    ctx.save();
    ctx.textAlign = p.align;
    ctx.letterSpacing = p.letterSpacing ? p.letterSpacing + 'px' : '0';

    // font
    const fontArr = [p.size + 'px ' + (p.font ? p.font : 'sans-serif')];
    if (p.isBold) {
        fontArr.unshift('bold');
    }
    if (p.isItalic) {
        fontArr.unshift('italic');
    }
    ctx.font = fontArr.join(' ');

    // fill
    ctx.fillStyle = p.fill ? BB.ColorConverter.toRgbaStr(p.fill.color) : 'transparent';

    // stroke
    ctx.strokeStyle = p.stroke ? BB.ColorConverter.toRgbaStr(p.stroke.color) : 'transparent';
    if (p.stroke) {
        ctx.lineWidth = p.stroke.lineWidth;
        ctx.lineJoin = 'round';
    }

    ctx.translate(p.x, p.y);
    ctx.rotate(-p.angleRad);

    const lines = p.text.split('\n').map(line => line.replaceAll('\t', '    '));

    // bounds
    const bounds: IBounds = {
        x1: 0,
        y1: 0,
        x2: 0,
        y2: 0,
    };
    {
        let isFirst = true;
        lines.forEach((line, lineIndex) => {
            const metrics = ctx.measureText(line);
            const x = 0;
            const y =  p.size * (p.lineHeight ?? 1) * lineIndex;
            const mRect = textMetricToRect(metrics, p.align);
            if (isFirst) {
                isFirst = false;
                bounds.x1 = x + mRect.x;
                bounds.y1 = y + mRect.y;
                bounds.x2 = x + mRect.x + mRect.width;
                bounds.y2 = y + mRect.y + mRect.height;
            } else {
                bounds.x1 = Math.min(bounds.x1, x + mRect.x);
                bounds.y1 = Math.min(bounds.y1, y + mRect.y);
                bounds.x2 = Math.max(bounds.x2, x + mRect.x + mRect.width);
                bounds.y2 = Math.max(bounds.y2, y + mRect.y + mRect.height);
            }
        });
    }

    // draw stroke
    lines.forEach((line, lineIndex) => {
        const x = 0;
        const y =  p.size * (p.lineHeight ?? 1) * lineIndex;
        ctx.strokeText(line, x, y);
    });

    // draw fill
    lines.forEach((line, lineIndex) => {
        const x = 0;
        const y =  p.size * (p.lineHeight ?? 1) * lineIndex;
        ctx.fillText(line, x, y);
    });

    ctx.restore();
    return {
        x: bounds.x1,
        y: bounds.y1,
        width: bounds.x2 - bounds.x1,
        height: bounds.y2 - bounds.y1,
    };
}