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

export function appendTextDiv (target: HTMLElement, text: string): HTMLDivElement {
    const div = document.createElement('div');
    div.innerHTML = text;
    target.append(div);
    return div;
}

/**
 * Is an input element focused.
 * Set attribute "data-ignore-focus" to "true" if its focus should be ignored.
 *
 * @param getAll - check all, even those with "data-ignore-focus" = "true"
 */
export function isInputFocused (getAll: boolean = false): boolean {
    const result: boolean = !!document.activeElement && ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName);
    if (getAll) {
        return result;
    } else {
        return result && !document.activeElement?.getAttribute('data-ignore-focus');
    }
}

export function unfocusAnyInput (): void {
    if (isInputFocused(true)) {
        /*
            Unfocus anything that is focused.

            If an Input is focused in Firefox, and it gets detached from the DOM via a Node
            that isn't its direct parent, then Firefox will keep anything attached to this
            Input in memory. It will not be garbage collected until a new Input is focused.

            Workaround: Temporarily create an input, focus it, detach it.
             */
        const focusEl = BB.el({
            parent: document.body,
            tagName: 'input',
            css: {
                opacity: '0',
                width: '0',
                height: '0',
            },
        });
        setTimeout(() => {
            focusEl.select();
            focusEl.focus();
            focusEl.remove();
        }, 10);
    }
}

/**
 * clears text selection in window
 */
export function clearSelection (): void {
    if (window.getSelection) {
        const sel = window.getSelection();
        if (sel) {
            if (sel.empty) {
                sel.empty();
            } else if (sel.removeAllRanges) {
                sel.removeAllRanges();
            }
        }
    }
}

/**
 * prevents being able to focus element.
 * warning: it creates a listener
 *
 * @param el - dom element
 */
export const makeUnfocusable = (function (): ((el: HTMLElement) => void) {

    function preventFocus (event: FocusEvent): void {
        event.preventDefault();
        let didFocusRelated = false;
        if (event.relatedTarget) {
            try {
                (event.relatedTarget as HTMLElement).focus();
                didFocusRelated = true;
            } catch(e) {
                console.error('failed to focus');
            }
        }
        if (!didFocusRelated) {
            (event.currentTarget as HTMLElement).blur();
        }
    }

    return function (el) {
        el.setAttribute('tabindex', '-1');
        el.addEventListener('focus', preventFocus);
    };
})();

const els: {
    el: HTMLElement;
    listeners: [keyof HTMLElementEventMap, EventListener][];
}[] = [];
// window['els'] = els;

/**
 *
 * Create DOM element - div by default
 * params = {
 * 	    parent: someOtherDiv,
 * 	    css: {
 * 		    width: "500px",
 * 		    backgroundColor: "#fff"
 * 	    },
 * 	    content: "test", //or  content: [divA, divB, divC]   or content: someDiv
 * 	    className: "bla",
 *      id: "bla"
 * }
 *
 *  If onClick or onChange is used, then BB.destroyEl MUST be called
 *  to prevent a memory leak.
 *
 * @param params
 */
export function el <GTag extends keyof HTMLElementTagNameMap = 'div'> (
    params?: {
        parent?: HTMLElement;
        css?: IKeyStringOptional;
        custom?: { [key: string]: string };
        content?: string | (HTMLElement | string | undefined)[] | Element;
        textContent?: string;
        className?: string;
        title?: string;
        id?: string;
        tagName?: GTag;
        onClick?: (e: Event) => void;
        onChange?: (e: Event) => void;
    }
) {
    if (!params) {
        return document.createElement('div') as HTMLElementTagNameMap[GTag];
    }
    const result = document.createElement(params.tagName ? params.tagName : 'div');
    params.css && css(result, params.css);

    if (params.content) {
        if (typeof params.content === typeof 'aa') {
            result.innerHTML = params.content as string;

        } else if (Array.isArray(params.content)) {
            BB.append(result, params.content);

        } else {
            result.append(params.content as HTMLElement);

        }
    }
    if (params.textContent) {
        result.textContent = params.textContent;
    }
    if (params.className) {
        result.className = params.className;
    }
    if (params.id) {
        result.id = params.id;
    }
    if (params.parent) {
        params.parent.append(result);
    }
    if ('title' in params && params.title !== undefined) {
        result.title = params.title;
    }
    const listeners: [keyof HTMLElementEventMap, EventListener][] = [];
    if (params.onClick !== undefined) {
        result.addEventListener('click', params.onClick);
        listeners.push(['click', params.onClick as EventListener]);
    }
    if (params.onChange !== undefined) {
        result.addEventListener('change', params.onChange);
        listeners.push(['change', params.onChange]);
    }
    if (listeners.length > 0) {
        els.push({
            el: result,
            listeners,
        });
        /*div.style.backgroundColor = '#ff0';
        div.style.border = '1px solid #ff0';*/
    }
    if ('custom' in params && params.custom) {
        const customKeyArr = Object.keys(params.custom);
        for (let i = 0; i < customKeyArr.length; i++) {
            result.setAttribute(customKeyArr[i], params.custom[customKeyArr[i]]);
        }
    }
    return result as HTMLElementTagNameMap[GTag];
}

/**
 * removes event listeners for Elements created via el()
 * @param el
 */
export function destroyEl (el?: HTMLElement): void {
    if (!el) {
        return;
    }
    for (let i = 0; i < els.length; i++) {
        const item = els[i];
        if (item.el === el) {
            item.listeners.forEach(item => {
                el.removeEventListener(item[0], item[1]);
            });
            els.splice(i, 1);
            return;
        }
    }
    // not found
    return;
}


code:
export interface IVector2D {
    x: number;
    y: number;
}

export interface IPressureInput {
    x: number;
    y: number;
    pressure: number; // 0-1
}

export interface ISize2D {
    width: number;
    height: number;
}

export interface IKeyString {
    [key: string]: string;
}

export interface IKeyStringOptional {
    [key: string]: string | undefined;
}

export interface ISVG {
    elementType: string;
    childrenArr?: ISVG[];
    viewBox?: string;
    preserveAspectRatio?: string;
    id?: string;
    class?: string;
    width?: string;
    height?: string;
    fill?: string;
    stroke?: string;
    style?: string;
    d?: string;
    x?: string;
    y?: string;
    cx?: string;
    cy?: string;
    r?: string;
    rx?: string;
    x0?: string;
    y0?: string;
    x1?: string;
    y1?: string;
    x2?: string;
    y2?: string;
    offset?: string;
    'stop-color'?: string;
    'stroke-width'?: string;
    transform?: string;
    in?: string;
    in2?: string;
    operator?: string;
    stdDeviation?: string;
    result?: string;
    dx?: string;
    dy?: string;
    k2?: string;
    k3?: string;
    'flood-color'?: string;
    'flood-opacity'?: string;
    'vector-effect'?: string;
    points?: string;
    'transform-origin'?: string;
    opacity?: string;
    // add more when needed
}

export interface IBounds {
    x1: number;
    y1: number;
    x2: number;
    y2: number;
}

export interface IRect {
    x: number;
    y: number;
    width: number;
    height: number;
}

export interface ICropRect {
    left: number;
    right: number;
    top: number;
    bottom: number;
}

export type TNullable<T> = { [K in keyof T]: T[K] | null };

repo_name: Doggermelon/klecks

license: mit


code:
import {
    append,
    centerWithin,
    copyObj,
    createSvg,
    css,
    dateDayDifference,
    decToFraction,
    fitInto,
    gcd,
    getDate,
    handleClick,
    imageBlobToUrl,
    insertAfter, isDark,
    loadImage, nullToUndefined,
    reduce,
    setAttributes,
    shareCanvas,
    throwIfNull,
} from './base/base';
import {
    canShareFiles,
    eventUsesHighResTimeStamp,
    hasPointerEvents,
    isCssMinMaxSupported, unsetEventHandler,
} from './base/browser';
import {KeyListener, sameKeys} from './input/key-listener';
import {PointerListener} from './input/pointer-listener';
import {
    canvasBounds,
    convertToAlphaChannelCanvas,
    copyCanvas,
    createCheckerCanvas,
    createCheckerDataUrl,
    ctx,
    drawTransformedImageOnCanvas,
    drawTransformedImageWithBounds,
    freeCanvas,
    resizeCanvas,
    testShouldPixelate,
} from './base/canvas';
import {Matrix} from './math/matrix';
import {Vec2} from './math/vec2';
import {BezierLine, PointLine, projectPointOnLine, quadraticSplineInput, SplineInterpolator} from './math/line';
import {CMYK, ColorConverter, HSV, RGB, testIsWhiteBestContrast} from './color/color';
import {
    appendTextDiv,
    clearSelection,
    destroyEl,
    el,
    isInputFocused,
    makeUnfocusable,
    unfocusAnyInput,
} from './base/ui';
import {
    boundsInArea,
    clamp,
    dist,
    distSquared,
    intDxy,
    isInsideRect,
    lenSquared,
    mix,
    pointsToAngleDeg,
    pointsToAngleRad,
    rotate,
    rotateAround,
    round,
    roundEven,
    roundUneven,
    updateBounds,
} from './math/math';
import {createCanvas} from './base/create-canvas';
import {BbLog} from './base/bb-log';
import {LocalStorage} from './base/local-storage';
import {CoalescedExploder} from './input/event-chain/coalesced-exploder';
import {NFingerTapper} from './input/event-chain/n-finger-tapper';
import {PinchZoomer} from './input/event-chain/pinch-zoomer';
import {DoubleTapper} from './input/event-chain/double-tapper';
import {OnePointerLimiter} from './input/event-chain/one-pointer-limiter';
import {EventChain} from './input/event-chain/event-chain';

export const BB = {

    // ---- browser ----
    eventUsesHighResTimeStamp,
    hasPointerEvents,
    isCssMinMaxSupported,
    canShareFiles,
    unsetEventHandler,

    // ---- base ----
    insertAfter,
    loadImage,
    css,
    setAttributes,
    append,
    fitInto,
    centerWithin,
    getDate,
    gcd,
    reduce,
    decToFraction,
    imageBlobToUrl,
    dateDayDifference,
    copyObj,
    shareCanvas,
    handleClick,
    createSvg,
    BbLog,
    LocalStorage,
    throwIfNull,
    nullToUndefined,
    isDark,

    // ---- math ----
    mix,
    dist,
    distSquared,
    lenSquared,
    pointsToAngleRad,
    pointsToAngleDeg,
    isInsideRect,
    clamp,
    rotate,
    rotateAround,
    Matrix,
    Vec2,
    intDxy,
    roundEven,
    roundUneven,
    round,
    updateBounds,
    boundsInArea,

    // ---- line ----
    projectPointOnLine,
    PointLine,
    BezierLine,
    SplineInterpolator,
    quadraticSplineInput,

    // ---- canvas ----
    canvas: createCanvas,
    ctx,
    copyCanvas,
    testShouldPixelate,
    drawTransformedImageWithBounds,
    drawTransformedImageOnCanvas,
    createCheckerCanvas,
    createCheckerDataUrl,
    resizeCanvas,
    convertToAlphaChannelCanvas,
    freeCanvas,
    canvasBounds,

    // ---- color ----
    HSV,
    RGB,
    CMYK,
    ColorConverter,
    testIsWhiteBestContrast,

    // ---- UI ----
    appendTextDiv,
    clearSelection,
    makeUnfocusable,
    el,
    destroyEl,
    isInputFocused,
    unfocusAnyInput,

    // ---- events / input ----

    KeyListener,
    PointerListener,
    sameKeys,

    EventChain,
    DoubleTapper,
    NFingerTapper,
    PinchZoomer,
    CoalescedExploder,
    OnePointerLimiter,
};

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


code:
// based on js color conversion http://www.webtoolkit.info/

export class HSV {
    h: number;
    s: number;
    v: number;
    constructor (h: number, s: number, v: number) {
        this.h = Math.max(0, Math.min(360, h));
        this.s = Math.max(0.001, Math.min(100, s)); //bug when 0
        this.v = Math.max(0, Math.min(100, v));
    }
}

export class RGB {
    r : number;
    g: number;
    b: number;
    constructor (r: number, g: number, b: number) {
        this.r = Math.max(0, Math.min(255, r));
        this.g = Math.max(0, Math.min(255, g));
        this.b = Math.max(0, Math.min(255, b));
    }
}

export class CMYK {
    c: number;
    m: number;
    y: number;
    k: number;
    constructor (c: number, m: number, y: number, k: number) {
        this.c = Math.max(0, Math.min(100, c));
        this.m = Math.max(0, Math.min(100, m));
        this.y = Math.max(0, Math.min(100, y));
        this.k = Math.max(0, Math.min(100, k));
    }
}

export const ColorConverter = {

    _RGBtoHSV: function (RGB: RGB): HSV {
        const result = new HSV(0, 0, 0);

        const r = RGB.r / 255;
        const g = RGB.g / 255;
        const b = RGB.b / 255;

        const minVal = Math.min(r, g, b);
        const maxVal = Math.max(r, g, b);
        const delta = maxVal - minVal;

        result.v = maxVal;

        if (delta == 0) {
            result.h = 0;
            result.s = 0;
        } else {
            result.s = delta / maxVal;
            const del_R = (((maxVal - r) / 6) + (delta / 2)) / delta;
            const del_G = (((maxVal - g) / 6) + (delta / 2)) / delta;
            const del_B = (((maxVal - b) / 6) + (delta / 2)) / delta;

            if (r == maxVal) {
                result.h = del_B - del_G;
            } else if (g == maxVal) {
                result.h = (1 / 3) + del_R - del_B;
            } else if (b == maxVal) {
                result.h = (2 / 3) + del_G - del_R;
            }

            if (result.h < 0) {
                result.h += 1;
            }
            if (result.h > 1) {
                result.h -= 1;
            }
        }

        result.h = Math.round(result.h * 360);
        result.s = Math.round(result.s * 100);
        result.v = Math.round(result.v * 100);

        return result;
    },

    _HSVtoRGB: function (HSV: HSV): RGB {
        const result = new RGB(0, 0, 0);

        let var_h, var_i, var_1, var_2, var_3, var_r, var_g, var_b;

        const h = (HSV.h / 360) % 1;
        const s = HSV.s / 100;
        const v = HSV.v / 100;

        if (s == 0) {
            result.r = v * 255;
            result.g = v * 255;
            result.b = v * 255;
        } else {
            var_h = h * 6;
            var_i = Math.floor(var_h);
            var_1 = v * (1 - s);
            var_2 = v * (1 - s * (var_h - var_i));
            var_3 = v * (1 - s * (1 - (var_h - var_i)));

            if (var_i == 0) {
                var_r = v;
                var_g = var_3;
                var_b = var_1;
            } else if (var_i == 1) {
                var_r = var_2;
                var_g = v;
                var_b = var_1;
            } else if (var_i == 2) {
                var_r = var_1;
                var_g = v;
                var_b = var_3;
            } else if (var_i == 3) {
                var_r = var_1;
                var_g = var_2;
                var_b = v;
            } else if (var_i == 4) {
                var_r = var_3;
                var_g = var_1;
                var_b = v;
            } else {
                var_r = v;
                var_g = var_1;
                var_b = var_2;
            }

            result.r = var_r * 255;
            result.g = var_g * 255;
            result.b = var_b * 255;

            result.r = Math.round(result.r);
            result.g = Math.round(result.g);
            result.b = Math.round(result.b);
        }

        return result;
    },

    _CMYKtoRGB: function (CMYK: CMYK): RGB {
        const result = new RGB(0, 0, 0);

        const c = CMYK.c / 100;
        const m = CMYK.m / 100;
        const y = CMYK.y / 100;
        const k = CMYK.k / 100;

        result.r = 1 - Math.min(1, c * (1 - k) + k);
        result.g = 1 - Math.min(1, m * (1 - k) + k);
        result.b = 1 - Math.min(1, y * (1 - k) + k);

        result.r = Math.round(result.r * 255);
        result.g = Math.round(result.g * 255);
        result.b = Math.round(result.b * 255);

        return result;
    },

    _RGBtoCMYK: function (RGB: RGB): CMYK {
        const result = new CMYK(0, 0, 0, 0);

        const r = RGB.r / 255;
        const g = RGB.g / 255;
        const b = RGB.b / 255;

        result.k = Math.min(1 - r, 1 - g, 1 - b);
        result.c = (1 - r - result.k) / (1 - result.k);
        result.m = (1 - g - result.k) / (1 - result.k);
        result.y = (1 - b - result.k) / (1 - result.k);

        result.c = Math.round(result.c * 100);
        result.m = Math.round(result.m * 100);
        result.y = Math.round(result.y * 100);
        result.k = Math.round(result.k * 100);

        return result;
    },

    toRGB: function (o: RGB | HSV | CMYK): RGB {
        if (o instanceof RGB) {
            return o;
        }
        if (o instanceof HSV) {
            return this._HSVtoRGB(o);
        }
        if (o instanceof CMYK) {
            return this._CMYKtoRGB(o);
        }
        throw new Error('unknown type');
    },

    toHSV: function (o: RGB | HSV | CMYK): HSV {
        if (o instanceof HSV) {
            return o;
        }
        if (o instanceof RGB) {
            return this._RGBtoHSV(o);
        }
        if (o instanceof CMYK) {
            return this._RGBtoHSV(this._CMYKtoRGB(o));
        }
        throw new Error('unknown type');
    },

    toCMYK: function (o: RGB | HSV | CMYK): CMYK {
        if (o instanceof CMYK) {
            return o;
        }
        if (o instanceof RGB) {
            return this._RGBtoCMYK(o);
        }
        if (o instanceof HSV) {
            return this._RGBtoCMYK(this._HSVtoRGB(o));
        }
        throw new Error('unknown type');
    },
    toHexString: function (o: RGB): string {
        if (o instanceof RGB || ('r' in o && 'g' in o && 'b' in o)) {
            let ha = (parseInt('' + o.r)).toString(16);
            let hb = (parseInt('' + o.g)).toString(16);
            let hc = (parseInt('' + o.b)).toString(16);
            if (ha.length == 1) {
                ha = '0' + ha;
            }
            if (hb.length == 1) {
                hb = '0' + hb;
            }
            if (hc.length == 1) {
                hc = '0' + hc;
            }
            return ha + hb + hc;
        }
        return '#000';
    },
    toRgbStr: function (rgbObj: {r: number; g: number; b: number}): string {
        return 'rgb(' + Math.round(rgbObj.r) + ', ' + Math.round(rgbObj.g) + ', ' + Math.round(rgbObj.b) + ')';
    },
    toRgbaStr: function (rgbaObj: {r: number; g: number; b: number; a: number}): string {
        return 'rgba(' + Math.round(rgbaObj.r) + ', ' + Math.round(rgbaObj.g) + ', ' + Math.round(rgbaObj.b) + ', ' + rgbaObj.a + ')';
    },
    hexToRGB: function (hexStr: string): RGB | undefined {
        hexStr = hexStr.trim();
        // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
        const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
        hexStr = hexStr.replace(shorthandRegex, function (m, r, g, b) {
            return r + r + g + g + b + b;
        });

        const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hexStr);
        return result ? new RGB(
            parseInt(result[1], 16),
            parseInt(result[2], 16),
            parseInt(result[3], 16)
        ) : undefined;
    },

};

export function testIsWhiteBestContrast (rgbObj: {r: number; g: number; b: number}): boolean {
    return (rgbObj.r * 0.299 + rgbObj.g * 0.587 + rgbObj.b * 0.114) < 125;
}


code:
import {IPointerEvent} from '../event.types';
import {copyObj} from '../../base/base';

export interface ICoalescedPointerEvent extends IPointerEvent {
    isCoalesced?: true;
}

/**
 * A ChainElement. Splits up coalesced events into their own pointermove events. Otherwise regular pass through.
 *
 * in: IPointerEvent
 * out: IPointerEvent with property isCoalesced: boolean
 *
 * todo: eventPreventDefault and eventStopPropagation are broken events w coalesced events. (because of json parse json stringify)
 * but how could that even work?
 */
export class CoalescedExploder {

    private chainOut: ((e: ICoalescedPointerEvent) => void) | undefined;

    // ---- public ----

    setChainOut (func: (e: ICoalescedPointerEvent) => void) {
        this.chainOut = func;
    }

    chainIn (event: IPointerEvent): IPointerEvent | null {
        if (event.type === 'pointermove') {

            if (event.coalescedArr && event.coalescedArr.length > 0) {
                for (let i = 0; i < event.coalescedArr.length; i++) {
                    const eventCopy: ICoalescedPointerEvent = copyObj(event);
                    if (i === 0) {
                        eventCopy.coalescedArr = [];
                    }
                    const coalescedItem = event.coalescedArr[i];

                    eventCopy.pageX = coalescedItem.pageX;
                    eventCopy.pageY = coalescedItem.pageY;
                    eventCopy.relX = coalescedItem.relX;
                    eventCopy.relY = coalescedItem.relY;
                    eventCopy.dX = coalescedItem.dX;
                    eventCopy.dY = coalescedItem.dY;
                    eventCopy.time = coalescedItem.time;
                    if (i < event.coalescedArr.length - 1) {
                        eventCopy.isCoalesced = true;
                    }

                    this.chainOut && this.chainOut(eventCopy);
                }

            } else {
                return event;
            }
        } else {
            return event;
        }

        return null;
    }

}


code:
import {TChainOutFunc} from './event-chain.types';
import {dist} from '../../math/math';
import {IPointerEvent, TPointerButton, TPointerType} from '../event.types';

export interface IDoubleTapperEvent {
    pageX: number;
    pageY: number;
    relX: number;
    relY: number;
}

type TDoubleTapperTimeoutType = 'fail' | 'maxUntilSecondDown' | 'success';

/**
 * A ChainElement. Detects double taps.
 *
 * in IPointerEvent
 * out IPointerEvent
 */
export class DoubleTapper {

    private readonly onDoubleTap: (e: IDoubleTapperEvent) => void;
    private chainOut: TChainOutFunc | undefined;
    private allowedPointerTypeArr: TPointerType[] = ['touch', 'mouse', 'pen'];
    private allowedButtonArr: TPointerButton[] = ['left'];
    private minSilenceBeforeDurationMs: number = 400;
    private maxPressedDurationMs: number = 300;
    private maxPressedDistancePx: number = 10;
    private maxInbetweenDistancePx: number = 19;
    private maxUpToUpDurationMs: number = 500;
    private maxUntilSecondDownDurationMs: number = 300;
    private minSilenceAfterMs: number = 250;
    private sequenceArr: (
        {
            isDown: boolean;
            time: number;
            position: [number, number];
            pointerId: number;
        } |
        {
            isUp: boolean;
            time: number;
            position: [number, number];
        } |
        {
            pageX: number;
            pageY: number;
            relX: number;
            relY: number;
        }
        )[] = [];
    private pointersDownIdArr: number[] = [];
    private lastUpTime: number = 0;
    private nowTime: number = 0;
    private eventQueueArr: IPointerEvent[] = [];
    private timeoutObj: {
        [K in TDoubleTapperTimeoutType]: ReturnType<typeof setTimeout> | null;
    } = {
        fail: null,
        maxUntilSecondDown: null,
        success: null,
    };
    private readonly gestureFailed: () => void;

    // double tap achieved
    private success (): void {
        this.timeoutObj.fail = null;
        this.timeoutObj.success = null;
        this.eventQueueArr = []; // events get swallowed
        const lastSequenceItem = this.sequenceArr[this.sequenceArr.length - 1];
        this.sequenceArr = [];
        if ('pageX' in lastSequenceItem) {
            this.onDoubleTap({
                pageX: lastSequenceItem.pageX,
                pageY: lastSequenceItem.pageY,
                relX: lastSequenceItem.relX,
                relY: lastSequenceItem.relY,
            });
        }
    }

    // returns false if time already up. otherwise sets up timeout
    private setupTimeout (timeoutStr: TDoubleTapperTimeoutType, targetFunc: () => void, timeMS: number): boolean {
        const diff = timeMS - this.nowTime;
        // console.log(fingers + ': ' + timeoutStr + ' diff', diff);
        if (diff <= 0) { // time already up
            return false;
        }
        this.timeoutObj[timeoutStr] = setTimeout(targetFunc, diff);
        return true;
    }

    /**
     * @param event object - a pointer event from BB.PointerListener
     */
    private processEvent (event: IPointerEvent): void {

        if (event.type === 'pointerdown') {
            this.pointersDownIdArr.push(event.pointerId);
        } else if (event.type === 'pointerup') {
            for (let i = 0; i < this.pointersDownIdArr.length; i++) {
                if (this.pointersDownIdArr[i] === event.pointerId) {
                    this.pointersDownIdArr.splice(i, 1);
                    break;
                }
            }
        }

        if (!this.allowedPointerTypeArr.includes(event.pointerType)) { //wrong input type -> fail
            //console.log('wrong input type -> fail');
            this.gestureFailed();
            return;
        }

        this.nowTime = performance.now();
        const lastSequenceItem = this.sequenceArr.length > 0 ? this.sequenceArr[this.sequenceArr.length - 1] : null;
        if (event.type === 'pointerup') {
            this.lastUpTime = event.time;
        }

        if (event.type === 'pointerdown') {
            if (this.pointersDownIdArr.length > 1) { // more than one pointer down -> fail
                //console.log('more than one pointer down -> fail');
                this.gestureFailed();
                return;
            }
            if (this.timeoutObj.success !== null) { // silence-after not achieved -> fail
                //console.log('silence-after not achieved -> fail');
                this.gestureFailed();
                return;
            }
            if (this.sequenceArr.length === 0 && this.nowTime - this.lastUpTime < this.minSilenceBeforeDurationMs) { // silence before not achieved -> fail
                //console.log('silence before not achieved -> fail');
                this.gestureFailed();
                return;
            }
            if (event.button && !this.allowedButtonArr.includes(event.button)) { // wrong button -> fail
                //console.log('wrong button -> fail', event.button, allowedButtonArr);
                this.gestureFailed();
                return;
            }
            if ((lastSequenceItem && 'isDown' in lastSequenceItem && lastSequenceItem.isDown) || this.sequenceArr.length > 2) { // jumbled -> fail
                //console.log('jumbled -> fail');
                this.gestureFailed();
                return;
            }
            if (lastSequenceItem && 'position' in lastSequenceItem) {
                const distance = dist(lastSequenceItem.position[0], lastSequenceItem.position[1], event.pageX, event.pageY);
                if (distance > this.maxInbetweenDistancePx) { //moved too much -> reset
                    //console.log('maxInbetweenDistancePx -> reset');
                    this.gestureFailed();

                    if ('time' in lastSequenceItem && this.nowTime - lastSequenceItem.time < this.minSilenceBeforeDurationMs) { //silence before not achieved -> fail
                        return;
                    }
                }
            }
            this.sequenceArr.push({
                isDown: true,
                time: this.nowTime,
                position: [event.pageX, event.pageY],
                pointerId: event.pointerId,
            });
            //maxUntilSecondDown

            if (this.sequenceArr.length > 1) {
                this.timeoutObj.maxUntilSecondDown && clearTimeout(this.timeoutObj.maxUntilSecondDown);
            } else if (!this.setupTimeout('maxUntilSecondDown', () => this.gestureFailed(), event.time + this.maxUntilSecondDownDurationMs)) {
                //console.log('event.time + maxPressedDurationMs -> fail');
                this.gestureFailed();
                return;
            }

            this.timeoutObj.fail && clearTimeout(this.timeoutObj.fail);
            if (!this.setupTimeout('fail', () => this.gestureFailed(), event.time + this.maxPressedDurationMs)) {
                //console.log('event.time + maxPressedDurationMs -> fail');
                this.gestureFailed();
                return;
            }
        }
        if (lastSequenceItem && event.type === 'pointermove' && 'pointerId' in lastSequenceItem && lastSequenceItem.pointerId === event.pointerId) {
            /*if (lastSequenceItem.pointerId !== event.pointerId) { //another pointer mixing in -> fail
                console.log('another pointer mixing in -> fail');
                this.fail();
                return;
            }*/
            const distance = dist(lastSequenceItem.position[0], lastSequenceItem.position[1], event.pageX, event.pageY);
            if (distance > this.maxPressedDistancePx) { //moved too much -> fail
                //console.log('maxPressedDistancePx -> fail');
                this.gestureFailed();
                return;
            }
        }
        if (lastSequenceItem && event.type === 'pointerup') {
            if ('pointerId' in lastSequenceItem && lastSequenceItem.pointerId !== event.pointerId) { //another pointer mixing in -> fail
                this.gestureFailed();
                return;
            }
            if ('time' in lastSequenceItem && this.nowTime >= lastSequenceItem.time + this.maxPressedDurationMs) { //pressed too long -> fail
                this.gestureFailed();
                return;
            }
            this.timeoutObj.fail && clearTimeout(this.timeoutObj.fail);

            if (this.sequenceArr.length < 3) {
                if (!this.setupTimeout('fail', () => this.gestureFailed(), event.time + this.maxUpToUpDurationMs)) {
                    this.gestureFailed();
                    return;
                }

                this.sequenceArr = [
                    lastSequenceItem,
                    {
                        isUp: true,
                        time: this.nowTime,
                        position: [event.pageX, event.pageY],
                    },
                ];
                return;
            }

            if ('time' in this.sequenceArr[1] && this.nowTime < this.sequenceArr[1].time + this.maxUpToUpDurationMs) {
                // double tap almost success
                // only needs silence
                this.sequenceArr.push({
                    pageX: event.pageX,
                    pageY: event.pageY,
                    relX: event.relX,
                    relY: event.relY,
                });
                if (!this.setupTimeout('success', () => this.success(), event.time + this.minSilenceAfterMs)) {
                    this.gestureFailed();
                }
            } else { // time up -> fail
                this.gestureFailed();
            }

        }
    }

    // ---- public ----
    constructor (
        p: {
            onDoubleTap: (e: IDoubleTapperEvent) => void; // fires when double tap occurs
        }
    ) {
        this.onDoubleTap = p.onDoubleTap;
        this.gestureFailed = () => {
            if (this.sequenceArr.length === 0) { // no gesture started -> can be ignored
                return;
            }
            this.timeoutObj.fail && clearTimeout(this.timeoutObj.fail);
            this.timeoutObj.maxUntilSecondDown && clearTimeout(this.timeoutObj.maxUntilSecondDown);
            this.timeoutObj.success && clearTimeout(this.timeoutObj.success);
            this.timeoutObj.fail = null;
            this.timeoutObj.maxUntilSecondDown = null;
            this.timeoutObj.success = null;

            if (this.chainOut) {
                for (let i = 0; i < this.eventQueueArr.length; i++) {
                    this.chainOut(this.eventQueueArr[i]);
                }
            }

            this.eventQueueArr = [];
            this.sequenceArr = [];
        };
    }

    chainIn (event: IPointerEvent): IPointerEvent | null {

        this.processEvent(event);

        if (this.sequenceArr.length === 0) { //existing events can not become a double tap
            this.gestureFailed();
            return event;
        }
        // events might become a double tap -> queue
        this.eventQueueArr.push(event);
        return null;
    }

    setChainOut (func: TChainOutFunc): void {
        this.chainOut = func;
    }

    setAllowedPointerTypeArr (arr: TPointerType[]): void {
        this.allowedPointerTypeArr = [...arr];
    }

    setAllowedButtonArr (arr: TPointerButton[]): void {
        this.allowedButtonArr = [...arr];
    }

}


code:
import {IChainElement, TChainOutFunc} from './event-chain.types';
import {IPointerEvent} from '../event.types';

/**
 * for chaining event processing. useful for gestures (double tap, pinch zoom, max pointer filter).
 * each element in the chain might hold back the events, swallow them, transform them, or create new ones
 */
export class EventChain {
    private readonly chainArr: IChainElement[];
    private chainOut: TChainOutFunc | undefined;

    private continueChain (i: number, event: IPointerEvent): null {
        for (; i < this.chainArr.length; i++) {
            event = this.chainArr[i].chainIn(event);
            if (event === null) {
                return null;
            }
        }
        this.chainOut && this.chainOut(event);
        return null;
    }

    // ---- public ----
    constructor (
        p: {
            chainArr: IChainElement[];
        }
    ) {
        this.chainArr = p.chainArr;

        for (let i = 0; i < this.chainArr.length; i++) {
            ( (i) => {
                this.chainArr[i].setChainOut((event: IPointerEvent) => {
                    this.continueChain(i + 1, event);
                });
            })(i);
        }

    }

    /**
     * feed an event into the chain
     */
    chainIn (event: IPointerEvent): null {
        return this.continueChain(0, event);
    }

    /**
     * func will be called when event has passed through the chain
     */
    setChainOut (func: TChainOutFunc): void {
        this.chainOut = func;
    }

}


code:
import {IPointerEvent} from '../event.types';

export type TChainOutFunc = (event: IPointerEvent) => void;

export interface IChainElement {
    chainIn: (event: IPointerEvent) => IPointerEvent;
    setChainOut: (f: (event: IPointerEvent) => void) => void;
}


code:
import {dist} from '../../math/math';
import {IPointerEvent} from '../event.types';

/**
 * A ChainElement. Detects a single tap with N 'touch' pointers
 *
 * in IPointerEvent
 * out IPointerEvent
 */
export class NFingerTapper {

    private readonly minSilenceBeforeDurationMs = 50;
    private readonly maxTapMs = 500;
    private readonly maxFirstLastFingerDownMs = 250;
    private readonly maxPressedDistancePx = 12; //5 + fingers * 5;

    private chainOut: ((e: IPointerEvent) => void) | undefined;
    private fingerArr: {
        pointerId: number;
        downTimeMs: number;
        downPageX: number;
        downPageY: number;
        isUp?: boolean;
    }[] = [];
    private eventQueueArr: IPointerEvent[] = [];
    private firstDownTime: number = 0;
    private lastEventTime: number = 0;
    private nowTime: number = performance.now();
    private readonly pointersDownIdArr: number[] = [];
    private readonly fingers: number;
    private readonly onTap: () => void;
    private readonly timeoutObj: {
        firstLastDownTimeout: ReturnType<typeof setTimeout> | null;
        tapTimeout: ReturnType<typeof setTimeout> | null;
    } = {
        firstLastDownTimeout: null,
        tapTimeout: null,
    };

    private failGesture (): void {
        if (this.eventQueueArr.length === 0) {
            return;
        }
        this.timeoutObj.firstLastDownTimeout && clearTimeout(this.timeoutObj.firstLastDownTimeout);
        this.timeoutObj.tapTimeout && clearTimeout(this.timeoutObj.tapTimeout);
        for (let i = 0; i < this.eventQueueArr.length; i++) {
            this.chainOut && this.chainOut(this.eventQueueArr[i]);
        }
        this.eventQueueArr = [];
        this.fingerArr = [];
    }

    private success (): void {
        this.timeoutObj.firstLastDownTimeout && clearTimeout(this.timeoutObj.firstLastDownTimeout);
        this.timeoutObj.tapTimeout && clearTimeout(this.timeoutObj.tapTimeout);
        this.eventQueueArr = []; // events get swallowed
        this.fingerArr = [];
        this.onTap();
    }

    private setupTimeout (timeoutStr: 'firstLastDownTimeout' | 'tapTimeout', timeMS: number): boolean {
        const diff = timeMS - this.nowTime;
        //console.log(fingers + ': ' + timeoutStr + ' diff', diff);
        if (diff <= 0) { // time already up
            return false;
        }
        this.timeoutObj[timeoutStr] = setTimeout(() => this.failGesture(), diff);
        return true;
    }

    private processEvent (event: IPointerEvent): true | void {

        const tempLastEventTime = this.lastEventTime;
        this.lastEventTime = event.time;

        if (event.type === 'pointerdown') {
            this.pointersDownIdArr.push(event.pointerId);
        } else if (event.type === 'pointerup') {
            for (let i = 0; i < this.pointersDownIdArr.length; i++) {
                if (this.pointersDownIdArr[i] === event.pointerId) {
                    this.pointersDownIdArr.splice(i, 1);
                    break;
                }
            }
        }

        if (event.pointerType !== 'touch') {
            if (this.fingerArr.length > 0) { // already in gesture -> fail
                this.failGesture();
            }
            return;
        }

        this.nowTime = performance.now();

        if (event.type === 'pointerdown') {
            //console.log('down');
            if (this.fingerArr.length + 1 !== this.pointersDownIdArr.length) { // failed before, and some fingers are still down -> fail
                this.failGesture();
                return;
            }
            if (this.fingerArr.length === this.fingers) { // too many fingers down -> fail
                //console.log(fingers + ': too many fingers down -> fail');
                this.failGesture();
                return;
            }
            if (this.fingerArr.length > 0 && event.time - this.maxFirstLastFingerDownMs > this.fingerArr[0].downTimeMs) { // took too long to touch with all fingers -> fail
                //console.log(fingers + ': took too long to touch with all fingers -> fail');
                this.failGesture();
                return;
            }
            if (this.fingerArr.length === 0 && event.time - this.minSilenceBeforeDurationMs < tempLastEventTime) { // not enough silence before -> fail
                //console.log(fingers + ': not enough silence before -> fail');
                this.failGesture();
                return;
            }

            if (this.fingerArr.length === 0) {
                this.firstDownTime = event.time;

                if (!this.setupTimeout('firstLastDownTimeout', event.time + this.maxFirstLastFingerDownMs) || !this.setupTimeout('tapTimeout', event.time + this.maxTapMs)) { // timeouts already up -> fail
                    this.failGesture();
                    return;
                }

            }
            this.fingerArr.push({
                pointerId: event.pointerId,
                downTimeMs: event.time,
                downPageX: event.pageX,
                downPageY: event.pageY,
            });
            return;

        }

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

            if (this.fingerArr.length === 0) {
                //not in a gesture -> ignore
                return;
            }

            let fingerObj = null;
            for (let i = 0; i < this.fingerArr.length; i++) {
                if (this.fingerArr[i].pointerId === event.pointerId) {
                    fingerObj = this.fingerArr[i];
                    break;
                }
            }
            if (fingerObj === null) { // finger not part of the tap is on screen -> fail
                this.failGesture();
                return;
            }

            if (event.time - this.maxTapMs > this.firstDownTime) { // tap took too long -> fail
                //console.log(fingers + ': tap took too long -> fail');
                this.failGesture();
                return;
            }

            const distance = dist(event.pageX, event.pageY, fingerObj.downPageX, fingerObj.downPageY);
            if (distance > this.maxPressedDistancePx) { // finger moved too much -> fail
                //console.log(fingers + ': a finger moved too much -> fail', distance);
                this.failGesture();
                return;
            }

        }

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

            if (this.fingerArr.length === 0) {
                //not in a gesture -> ignore
                return;
            }

            //console.log('up', event.pageX, event.pageY);
            if (this.fingerArr.length !== this.fingers) { // not enough fingers -> fail
                //console.log(fingers + ': not enough fingers -> fail');
                this.failGesture();
                return;
            }

            let fingerObj = null;
            let i = 0;
            for (; i < this.fingerArr.length; i++) {
                if (this.fingerArr[i].pointerId === event.pointerId) {
                    fingerObj = this.fingerArr[i];
                    break;
                }
            }
            if (fingerObj === null) {
                //do nothing
                return;
            }

            if (event.time - this.maxTapMs > this.firstDownTime) { // tap took too long -> fail
                //console.log(fingers + ': tap took too long -> fail');
                this.failGesture();
                return;
            }

            const distance = dist(event.pageX, event.pageY, fingerObj.downPageX, fingerObj.downPageY);
            if (distance > this.maxPressedDistancePx) { // finger moved too much -> fail
                //console.log(fingers + ': b finger moved too much -> fail', distance, event.pageX, event.pageY);
                //console.log(fingerArr);
                this.failGesture();
                return;
            }

            fingerObj.isUp = true;

            let allAreUp = true;
            for (let i = 0; i < this.fingerArr.length; i++) {
                if (!this.fingerArr[i].isUp) {
                    allAreUp = false;
                    break;
                }
            }
            //console.log('fingerArr', fingerArr);

            if (allAreUp) { // success
                this.success();
                return true;
            }

        }

    }

    // ---- public ----
    constructor (
        p: {
            fingers: number;
            onTap: () => void;
        }
    ) {
        this.fingers = p.fingers;
        this.onTap = p.onTap;
    }

    chainIn (event: IPointerEvent): (IPointerEvent | null) {

        const result = this.processEvent(event);

        //console.log(fingerArr.length);

        if (result === true) {
            //tap success -> event gets swallowed
            return null;
        }
        if (this.fingerArr.length === 0) {
            return event;
        } else {
            this.eventQueueArr.push(event);
        }

        return null;

    }

    setChainOut (func: (e: IPointerEvent) => void): void {
        this.chainOut = func;
    }

}


code:
import {TChainOutFunc} from './event-chain.types';
import {IPointerEvent} from '../event.types';

/**
 * only lets through events from one pointer at a time.
 *
 * in IPointerEvent
 * out IPointerEvent
 */
export class OnePointerLimiter {

    private chainOut: TChainOutFunc | undefined;
    private downPointerId: number | null = null;
    private readonly ignorePointerIdArr: number[] = [];

    // ---- public ----
    chainIn (event: IPointerEvent): IPointerEvent | null {

        if (this.ignorePointerIdArr.includes(event.pointerId)) {
            if (event.type === 'pointerup') {
                for (let i = 0; i < this.ignorePointerIdArr.length; i++) {
                    if (this.ignorePointerIdArr[i] === event.pointerId) {
                        this.ignorePointerIdArr.splice(i, 1);
                        break;
                    }
                }
            }
            return null;
        }

        if (this.downPointerId === null) {
            if (event.type === 'pointerdown') {
                this.downPointerId = event.pointerId;
            }
            return event;

        } else {
            if (event.pointerId !== this.downPointerId) {
                if (event.type === 'pointerdown') {
                    this.ignorePointerIdArr.push(event.pointerId);
                }
                return null;
            }
            if (event.type === 'pointerup') {
                this.downPointerId = null;
            }
            return event;
        }
    }

    setChainOut (func: TChainOutFunc): void {
        this.chainOut = func;
    }
}


code:
import {dist, pointsToAngleRad} from '../../math/math';
import {IPointerEvent} from '../event.types';

export type TPinchZoomerEvent = { type: 'end' } | {
    type: 'move';

    downRelX: number;
    downRelY: number;
    relX: number;
    relY: number;

    angleRad: number;
    scale: number;
};

type TTouchPointer =  {
    pointerId: number;
    relX: number;
    relY: number;
    downRelX?: number; // only for first
    downRelY?: number;
};

type TPinchGesture = {
    touchPointerArr: TTouchPointer[];
    otherPointerIdArr: number[];
    isInProgress: boolean;
};

/**
 * A ChainElement. Detects a pinch zooming (2 touch pointers). If one finger lifts, then will use the remaining.
 * Further pointers are ignored, but their events get swallowed during the pinching.
 * pinching ends when ALL pointers are lifted.
 * Events passed through if no pinching.
 *
 * in IPointerEvent
 * out IPointerEvent
 */
export class PinchZoomer {

    private readonly firstFingerMaxDistancePx = 10;
    private readonly untilSecondFingerDurationMs = 250;

    private chainOut: ((e: IPointerEvent) => void) | undefined;
    private readonly pointersDownIdArr: number[] = [];
    private gestureObj: null | TPinchGesture = null;
    private eventQueueArr: IPointerEvent[] = [];
    private nowTime: number = performance.now();
    private readonly timeoutObj: {
        secondFingerTimeout: ReturnType<typeof setTimeout> | null;
    } = {
        secondFingerTimeout: null,
    };
    private pincherArr: {
        pointerId: number;
        relX: number;
        relY: number;
        downRelX: number;
        downRelY: number;
    }[] = [];
    private readonly onPinch: (e: TPinchZoomerEvent) => void;

    private end (): void {
        this.gestureObj = null;
        this.eventQueueArr = [];
    }

    private fail (doSwallow?: boolean): void {
        if (!this.gestureObj) { // no gesture happening -> ignore
            return;
        }

        this.timeoutObj.secondFingerTimeout && clearTimeout(this.timeoutObj.secondFingerTimeout);
        if (!doSwallow) {
            for (let i = 0; i < this.eventQueueArr.length; i++) {
                this.chainOut && this.chainOut(this.eventQueueArr[i]);
            }
        }
        this.end();
    }

    private setupFailTimeout (timeMS: number): boolean {
        const diff = timeMS - this.nowTime;
        if (diff <= 0) { // time already up
            return false;
        }
        this.timeoutObj.secondFingerTimeout = setTimeout(() => this.fail(), diff);
        return true;
    }

    private processEvent (event: IPointerEvent): void {

        if (event.type === 'pointerdown') {
            this.pointersDownIdArr.push(event.pointerId);

        } else if (event.type === 'pointerup') {
            for (let i = 0; i < this.pointersDownIdArr.length; i++) {
                if (this.pointersDownIdArr[i] === event.pointerId) {
                    this.pointersDownIdArr.splice(i, 1);
                    break;
                }
            }
        }

        //pass through scenarios
        if (
            !this.gestureObj && (
                event.pointerType !== 'touch' || // wrong pointer type
                (event.type === 'pointermove' && this.pointersDownIdArr.length > 0) || // failed before
                this.pointersDownIdArr.length > 1 || // failed before
                event.type === 'pointerup' // failed before
            )
        ) {
            return;
        }

        this.nowTime = performance.now();

        //pointer down
        if (event.type === 'pointerdown') {

            if (this.gestureObj) {
                if (event.pointerType === 'touch') { // touch finger down - as nth pointer

                    this.gestureObj.touchPointerArr.push({
                        pointerId: event.pointerId,
                        relX: event.relX,
                        relY: event.relY,
                    });

                    if (this.gestureObj.isInProgress) {
                        this.continuePinch(this.gestureObj, {
                            type: 'down',
                            index: this.gestureObj.touchPointerArr.length - 1,
                        });
                    } else {
                        this.timeoutObj.secondFingerTimeout && clearTimeout(this.timeoutObj.secondFingerTimeout);
                        this.gestureObj.isInProgress = true;
                        this.beginPinch(this.gestureObj);
                    }
                    return;

                } else { // non-touch finger down - as nth pointer

                    if (this.gestureObj.isInProgress) {
                        this.gestureObj.otherPointerIdArr.push(event.pointerId);
                    } else { // second pointer wrong type -> fail
                        this.fail();
                    }
                    return;
                }

            } else {
                // first finger down - can only be touch if no gestureObj
                this.gestureObj = {
                    touchPointerArr: [{
                        pointerId: event.pointerId,
                        relX: event.relX,
                        relY: event.relY,
                        downRelX: event.relX,
                        downRelY: event.relY,
                    }],
                    otherPointerIdArr: [],
                    isInProgress: false,
                };
                if (!this.setupFailTimeout(event.time + this.untilSecondFingerDurationMs)) { // time ran out -> fail
                    this.fail();
                    return;
                }
                return;
            }

        }

        // should not happen. something went wrong
        if (!this.gestureObj) {
            // throw? would make it less robust
            this.fail();
            return;
        }

        //pointer move
        if (event.type === 'pointermove' && event.pointerType === 'touch') {
            //gesture object should always exist here

            let touchPointerObj: TTouchPointer | null = null;
            let i = 0;
            for (; i < this.gestureObj.touchPointerArr.length; i++) {
                if (event.pointerId === this.gestureObj.touchPointerArr[i].pointerId) {
                    touchPointerObj = this.gestureObj.touchPointerArr[i];
                    break;
                }
            }

            //null should not be possible. something went wrong
            if (!touchPointerObj) {
                // throw? would make it less robust
                this.fail();
                return;
            }

            touchPointerObj.relX = event.relX;
            touchPointerObj.relY = event.relY;

            if (!this.gestureObj.isInProgress) { // only one finger down & pinching hasn't started

                // should not happen. something went wrong
                if (
                    !('downRelX' in touchPointerObj && touchPointerObj.downRelX !== undefined) ||
                    !('downRelY' in touchPointerObj && touchPointerObj.downRelY !== undefined)
                ) {
                    this.fail();
                    return;
                }

                const distance = dist(touchPointerObj.downRelX, touchPointerObj.downRelY, touchPointerObj.relX, touchPointerObj.relY);
                if (distance > this.firstFingerMaxDistancePx) { // moved too much -> fail
                    this.fail();
                    return;
                }

            } else {
                if (i < 2) { // only first two touches can affect pinching
                    this.continuePinch(this.gestureObj, {
                        type: 'move',
                        index: i,
                    });
                }
            }

            return;
        }

        //pointer up
        if (event.type === 'pointerup') {
            //gesture object should always exist here

            if (event.pointerType === 'touch') {
                let i = 0;
                for (; i < this.gestureObj.touchPointerArr.length; i++) {
                    if (this.gestureObj.touchPointerArr[i].pointerId === event.pointerId) {
                        this.gestureObj.touchPointerArr.splice(i, 1);
                        break;
                    }
                }
                if (this.gestureObj.touchPointerArr.length > 0) {
                    this.continuePinch(this.gestureObj, {
                        type: 'up',
                        index: i,
                    });
                }

            } else { // non-touch
                for (let i = 0; i < this.gestureObj.otherPointerIdArr.length; i++) {
                    if (this.gestureObj.otherPointerIdArr[i] === event.pointerId) {
                        this.gestureObj.otherPointerIdArr.splice(i, 1);
                        break;
                    }
                }
            }

            //all fingers lifted?
            if (this.gestureObj.touchPointerArr.length === 0 && this.gestureObj.otherPointerIdArr.length === 0) {
                if (this.gestureObj.isInProgress) { // lifted last finger -> end of pinching
                    this.end();
                    this.endPinch();
                } else { // lifted finger again before pinching started -> fail
                    this.fail();
                }
                return;
            }

        }

    }

    private beginPinch (gestureObj: TPinchGesture): void {

        for (let i = 0; i < gestureObj.touchPointerArr.length; i++) {
            const pointerObj = gestureObj.touchPointerArr[i];
            this.pincherArr.push({
                pointerId: pointerObj.pointerId,
                relX: pointerObj.relX,
                relY: pointerObj.relY,
                downRelX: pointerObj.relX,
                downRelY: pointerObj.relY,
            });
        }

        const event: TPinchZoomerEvent = {
            type: 'move',

            // temp
            downRelX: 0,
            downRelY: 0,
            relX: 0,
            relY: 0,

            angleRad: 0,
            scale: 1,
        };

        if (this.pincherArr.length === 1) {
            event.relX = this.pincherArr[0].downRelX;
            event.relY = this.pincherArr[0].downRelY;
        } else {
            event.relX = 0.5 * (this.pincherArr[0].downRelX + this.pincherArr[1].downRelX);
            event.relY = 0.5 * (this.pincherArr[0].downRelY + this.pincherArr[1].downRelY);
        }
        event.downRelX = event.relX;
        event.downRelY = event.relY;

        this.onPinch(event);

    }

    //actionObj = {type: 'down'|'move'|'up', index: number}
    private continuePinch (
        gestureObj: TPinchGesture,
        actionObj: {
            type: 'down' | 'move' | 'up';
            index: number;
        }
    ): void {

        if (actionObj.index > 1) { // only first two pointers matter
            return;
        }

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

            let event: TPinchZoomerEvent;
            this.pincherArr[actionObj.index].relX = gestureObj.touchPointerArr[actionObj.index].relX;
            this.pincherArr[actionObj.index].relY = gestureObj.touchPointerArr[actionObj.index].relY;

            if (this.pincherArr.length === 1) {

                event = {
                    type: 'move',
                    downRelX: this.pincherArr[0].downRelX,
                    downRelY: this.pincherArr[0].downRelY,
                    relX: this.pincherArr[0].relX,
                    relY: this.pincherArr[0].relY,
                    angleRad: 0,
                    scale: 1,
                };

            } else {

                const startDist = dist(this.pincherArr[0].downRelX, this.pincherArr[0].downRelY, this.pincherArr[1].downRelX, this.pincherArr[1].downRelY);
                const distance = dist(this.pincherArr[0].relX, this.pincherArr[0].relY, this.pincherArr[1].relX, this.pincherArr[1].relY);

                const startAngle = pointsToAngleRad({
                    x: this.pincherArr[0].downRelX,
                    y: this.pincherArr[0].downRelY,
                }, {x: this.pincherArr[1].downRelX, y: this.pincherArr[1].downRelY});
                const angle = pointsToAngleRad({
                    x: this.pincherArr[0].relX,
                    y: this.pincherArr[0].relY,
                }, {x: this.pincherArr[1].relX, y: this.pincherArr[1].relY});

                event = {
                    type: 'move',
                    downRelX: 0.5 * (this.pincherArr[0].downRelX + this.pincherArr[1].downRelX),
                    downRelY: 0.5 * (this.pincherArr[0].downRelY + this.pincherArr[1].downRelY),
                    relX: 0.5 * (this.pincherArr[0].relX + this.pincherArr[1].relX),
                    relY: 0.5 * (this.pincherArr[0].relY + this.pincherArr[1].relY),
                    angleRad: angle - startAngle,
                    scale: distance / startDist,
                };

            }

            this.onPinch(event);

        } else if (actionObj.type === 'down' || actionObj.type === 'up') {
            this.endPinch();
            this.beginPinch(gestureObj);
        }

    }

    private endPinch (): void {
        this.pincherArr = [];
        this.onPinch({
            type: 'end',
        });
    }

    // ---- public ----
    constructor (
        p: {
            onPinch: (e: TPinchZoomerEvent) => void;
        }
    ) {
        this.onPinch = p.onPinch;
    }

    chainIn (event: IPointerEvent): IPointerEvent | null {
        this.processEvent(event);
        if (this.gestureObj) {
            if (!this.gestureObj.isInProgress) { // might still fail -> into queue
                this.eventQueueArr.push(event);
            }
        } else {
            return event;
        }
        return null;
    }

    setChainOut (func: (e: IPointerEvent) => void): void {
        this.chainOut = func;
    }

}


code:
export type TPointerEventType = 'pointerdown' | 'pointermove' | 'pointerup';
export type TPointerType = 'touch' | 'mouse' | 'pen';
export type TPointerButton = 'left' | 'middle' | 'right';

export interface IPointerEvent {
    type: TPointerEventType;
    pointerId: number; // long
    pointerType: TPointerType;
    pageX: number; // todo docs
    pageY: number;
    clientX: number; // todo docs
    clientY: number; // todo docs
    relX: number; // position relative to top left of target - todo what scale tho
    relY: number;
    dX: number; // movementX not supported by safari on iOS, so need my own
    dY: number;

    coalescedArr?: {
        pageX: number;
        pageY: number;
        clientX: number;
        clientY: number;
        relX: number; // position relative to top left of target
        relY: number;
        dX: number;
        dY: number;
        time: number; // same timescale as performance.now() - might be exact same number as in parent
    }[];
    time: number; // same timescale as performance.now()

    button?: TPointerButton;
    pressure?: number; // float [0,1] always 1 for touch and mouse
    downPageX?: number; // where was pointer when down-event occurred - set for down|move|up
    downPageY?: number;

    eventPreventDefault: () => void;
    eventStopPropagation: () => void;
}

export interface IWheelEvent {
    deltaY: number; // increments of 1
    pageX: number; // todo docs
    pageY: number;
    relX: number; // todo docs
    relY: number;
}


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

type TGlobalKey = {
    add: (keyListenerRef: TKeyListenerRef) => void;
    remove: (keyListenerRef: TKeyListenerRef) => void;
    getIsDown: () => TIsDown;
    getCombo: () => string[];
}

type TIsDown = {
    [key: string]: boolean;
};

export type TOnKeyDown = (keyStr: string, e: KeyboardEvent, comboStr: string, isRepeat?: boolean) => void;
export type TOnKeyUp = (keyStr: string, e: KeyboardEvent, oldComboStr: string) => void;
type TOnBlur = () => void;
type TKeyListenerRef = [
    TOnKeyDown | undefined,
    TOnKeyUp | undefined,
    TOnBlur | undefined,
];

const globalKey = ((): TGlobalKey => {

    // keyStr - our key naming system
    // key - KeyboardEvent.key
    // code - KeyboardEvent.code

    const keyStrToKeyObj = { // keyStr not to contain a '+', because that's used for the comboStr
        'space': [' ', 'Spacebar'], // Spacebar in IE
        'alt': ['Alt', 'AltGraph'],
        'shift': 'Shift',
        'ctrl': 'Control',
        'cmd': ['Meta', 'MetaLeft', 'MetaRight'],
        'enter': 'Enter',
        'esc': 'Escape',
        'backspace': 'Backspace',
        'delete': 'Delete',
        'sqbr_open': '[',
        'sqbr_close': ']',
        'a': ['a', 'A'],
        'b': ['b', 'B'],
        'c': ['c', 'C'],
        'e': ['e', 'E'],
        'f': ['f', 'F'],
        'g': ['g', 'G'],
        'r': ['r', 'R'], // when holding shift
        's': ['s', 'S'],
        't': ['t', 'T'],
        'u': ['u', 'U'],
        'x': ['x', 'X'],
        'y': ['y', 'Y'],
        'z': ['z', 'Z'],
        'plus': '+',
        'minus': '-',
        'left': 'ArrowLeft',
        'right': 'ArrowRight',
        'up': 'ArrowUp',
        'down': 'ArrowDown',
        'home': 'Home',
        'end': 'End',
    };

    // ['space', 'alt', ... ]
    const keyStrArr = Object.keys(keyStrToKeyObj);

    // { space: false, ... }
    const isDownObj: TIsDown = Object.entries(keyStrToKeyObj).reduce((acc, [key]) => {
        acc[key] = false;
        return acc;
    }, {} as TIsDown);

    // event.key to keyStr
    // { ArrowLeft: 'left', ... }
    const keyToKeyStrObj = Object.entries(keyStrToKeyObj).reduce((acc, [key, code]) => {
        if (typeof code === 'string') {
            acc[code] = key;
        } else {
            code.forEach(item => {
                acc[item] = key;
            });
        }
        return acc;
    }, {} as IKeyString);

    let comboArr: string[] = [];

    // a physical key's "key" can change as other keys get pressed. to keep track, need to also track the code
    // { KeyE: 'e', KeyF: undefined } - undefined - not down, string - the associated keyStr
    let codeIsDownObj: {
        [key: string]: string | undefined;
    } = {};
    const listenerArr: TKeyListenerRef[] = [];

    const emitDown: TOnKeyDown = function (a, b, c, d?): void {
        listenerArr.forEach(item => {
            if (!item[0]) {
                return;
            }
            item[0](a, b, c, d);
        });
    };

    const emitUp: TOnKeyUp = function (a, b, c): void {
        listenerArr.forEach(item => {
            if (!item[1]) {
                return;
            }
            item[1](a, b, c);
        });
    };

    const emitBlur: TOnBlur = function (): void {
        listenerArr.forEach(item => {
            if (!item[2]) {
                return;
            }
            item[2]();
        });
    };

    function keyDown (e: KeyboardEvent): void {
        const key = e.key;
        const code = e.code;

        if (key in keyToKeyStrObj) {
            const keyStr = keyToKeyStrObj[key];
            if (isDownObj[keyStr]) {
                emitDown(keyStr, e, comboArr.join('+'), true);
                return;
            }
            isDownObj[keyStr] = true;
            codeIsDownObj[code] = keyStr;

            //add to combo
            comboArr.push(keyStr);

            emitDown(keyStr, e, comboArr.join('+'));
        }
    }

    function keyUp (e: KeyboardEvent): void {
        const code = e.code;
        const oldComboStr = comboArr.join('+');

        // because of a macOS bug: when meta key is down, keyup of other keys does not fire.
        // https://stackoverflow.com/questions/25438608/javascript-keyup-isnt-called-when-command-and-another-is-pressed
        if ([
            'Meta', 'MetaLeft', 'MetaRight',
            'OSLeft', 'OSRight', // Firefox
        ].includes(code)) {
            blur();
            return;
        }

        const keyStr = codeIsDownObj[code];
        if (keyStr !== undefined) {
            isDownObj[keyStr] = false;
            codeIsDownObj[code] = undefined;

            // remove from combo
            for (let i = 0; i < comboArr.length; i++) {
                if (comboArr[i] == keyStr) {
                    comboArr.splice(i, 1);
                    i--;
                }
            }

            emitUp(keyStr, e, oldComboStr);
        }
    }

    function blur (): void {
        const oldComboStr = comboArr.join('+');
        comboArr = [];
        codeIsDownObj = {};

        const eventArr: string[] = [];
        keyStrArr.forEach(keyStr => {
            if (isDownObj[keyStr]) {
                isDownObj[keyStr] = false;
                eventArr.push(keyStr);
            }
        });
        for (let i = 0; i < eventArr.length; i++) {
            emitUp(
                eventArr[i],
                {
                    preventDefault: function () {},
                    stopPropagation: function () {},
                } as KeyboardEvent,
                oldComboStr,
            );
        }
        emitBlur();
    }

    return {
        add: (keyListenerRef: TKeyListenerRef): void => {
            if (listenerArr.includes(keyListenerRef)) {
                return;
            }
            const first = listenerArr.length === 0;
            listenerArr.push(keyListenerRef);

            if (first) {
                document.addEventListener('keydown', keyDown);
                document.addEventListener('keyup', keyUp);
                window.addEventListener('blur', blur);
            }
        },
        remove: (keyListenerRef: TKeyListenerRef): void => {
            if (!listenerArr.includes(keyListenerRef)) {
                return;
            }
            const last = listenerArr.length === 1;
            for (let i = 0; i < listenerArr.length; i++) {
                if (listenerArr[i] === keyListenerRef) {
                    listenerArr.splice(i, 1);
                    break;
                }
            }
            if (last) {
                document.removeEventListener('keydown', keyDown);
                document.removeEventListener('keyup', keyUp);
                window.removeEventListener('blur', blur);
            }
        },
        getIsDown: (): TIsDown => isDownObj,
        getCombo: (): string[] => comboArr,
    };
})();

/**
 * Listens to key events in window. Makes combos easier - e.g. ctrl + z
 *
 * keyStr - see in implementation - my representation of a key. e.g. 'r' can be 'r' and 'R'
 * comboStr - string joins currently pressed keyStr with a +
 *              e.g. 'ctrl+z'
 *
 */
export class KeyListener {

    private readonly onDown: TOnKeyDown | undefined;
    private readonly onUp: TOnKeyUp | undefined;
    private readonly onBlur: TOnBlur | undefined;
    private readonly ref: TKeyListenerRef;

    constructor (
        p: {
            onDown?: TOnKeyDown;
            onUp?: TOnKeyUp;
            onBlur?: TOnBlur;
        },
    ) {
        this.onDown = p.onDown;
        this.onUp = p.onUp;
        this.onBlur = p.onBlur;
        this.ref = [this.onDown, this.onUp, this.onBlur];
        globalKey.add(this.ref);
    }

    isPressed (keyStr: string): boolean {
        if (!(keyStr in globalKey.getIsDown())) {
            throw 'key "' + keyStr + '" not found';
        }
        return globalKey.getIsDown()[keyStr];
    }

    getComboStr (): string {
        return globalKey.getCombo().join('+');
    }

    comboOnlyContains (keyStrArr: string[]): boolean {
        for (let i = 0; i < globalKey.getCombo().length; i++) {
            if (!keyStrArr.includes(globalKey.getCombo()[i])) {
                return false;
            }
        }
        return true;
    }

    destroy (): void {
        globalKey.remove(this.ref);
    }
}

/**
 * Test, are the same keys pressed. Order does not matter.
 */
export function sameKeys (comboAStr: string, comboBStr: string): boolean {
    return comboAStr.split('+').sort().join('+') === comboBStr.split('+').sort().join('+');
}


code:
import {eventUsesHighResTimeStamp, hasPointerEvents, isFirefox} from '../base/browser';
import {WheelCleaner} from './wheel-cleaner';
import {IPointerEvent, IWheelEvent, TPointerButton, TPointerEventType, TPointerType} from './event.types';
import {PressureNormalizer} from './pressure-normalizer';

export interface IPointerListenerParams {
    target: HTMLElement | SVGElement;
    onPointer?: (pointerEvent: IPointerEvent) => void;
    onWheel?: (wheelEvent: IWheelEvent) => void;
    onEnterLeave?: (isOver: boolean) => void; // optional
    maxPointers?: number; // int [1,n] default is 1 - how many concurrent pointers to pay attention to
    fixScribble?: boolean; // fix ipad scribble issue - TODO remove, fixed start of 2022 -> https://bugs.webkit.org/show_bug.cgi?id=217430#c2
}

interface IPointer {
    pointerId: number;
    lastPageX: number | null;
    lastPageY: number | null;
}

interface IDragObj {
    pointerId: number; // long
    pointerType?: TPointerType;
    downPageX: number; //where was pointer when down-event occurred
    downPageY: number;
    buttons: number; // long
    lastPageX: number; //pageX in previous event - only for touch events, because they don't have movementX/Y
    lastPageY: number;
    lastTimeStamp?: number;
}

interface ICoalescedPointerEvent {
    pageX: number;
    pageY: number;
    clientX: number;
    clientY: number;
    movementX: number;
    movementY: number;
    timeStamp: number;
    pressure: number;
}

interface ICorrectedPointerEvent {
    pointerId: number;
    pointerType: string;
    pageX: number;
    pageY: number;
    clientX: number;
    clientY: number;
    movementX: number;
    movementY: number;
    timeStamp: number;
    pressure: number; // normalized
    buttons: number;
    button: number;
    coalescedArr: ICoalescedPointerEvent[];
    eventPreventDefault: () => void;
    eventStopPropagation: () => void;
}

interface TExtendedDOMPointerEvent extends PointerEvent {
    corrected: ICorrectedPointerEvent;
}

// keeping track of pointers for movement fallback
const pointerArr: IPointer[] = [];

function addPointer (event: ICorrectedPointerEvent): IPointer {
    const pointerObj: IPointer = {
        pointerId: event.pointerId,
        lastPageX: null,
        lastPageY: null,
    };
    pointerArr.push(pointerObj);

    if (pointerArr.length > 15) {
        pointerArr.shift();
    }

    return pointerObj;
}

function getPointer (event: ICorrectedPointerEvent): IPointer | null {
    for (let i = pointerArr.length - 1; i >= 0; i--) {
        if (event.pointerId === pointerArr[i].pointerId) {
            return pointerArr[i];
        }
    }
    return null;
}

function getButtonStr (buttons: number): TPointerButton | undefined {
    switch (buttons) {
        case 1:
            return 'left';
        case 2:
            return 'right';
        case 4:
            return 'middle';
        default:
            return undefined;
    }
}

const pressureNormalizer = new PressureNormalizer();
const timeStampOffset = eventUsesHighResTimeStamp() ? 0 : -performance.timing.navigationStart;

const pointerDownEvt = (hasPointerEvents ? 'pointerdown' : 'mousedown') as 'pointerdown';
const pointerMoveEvt = (hasPointerEvents ? 'pointermove' : 'mousemove') as 'pointermove';
const pointerUpEvt = (hasPointerEvents ? 'pointerup' : 'mouseup') as 'pointerup';
const pointerCancelEvt = (hasPointerEvents ? 'pointercancel' : 'mousecancel') as 'pointercancel';
const pointerLeaveEvt = (hasPointerEvents ? 'pointerleave' : 'mouseleave') as 'pointerleave';
const pointerEnterEvt = (hasPointerEvents ? 'pointerenter' : 'mouseenter') as 'pointerenter';

/**
 * More trustworthy pointer attributes. that behave the same across browsers.
 * returns a new object. Also attaches itself to the orig event. -> event.corrected
 */
function correctPointerEvent (event: PointerEvent | TExtendedDOMPointerEvent): ICorrectedPointerEvent {
    if ('corrected' in event) {
        return event.corrected;
    }

    function determineButtons (): number {
        if (event.buttons !== undefined) {
            return event.buttons;
        }
        /*
                button -> buttons
        none:	undefined -> 0
        left:	0 -> 1
        middle:	1 -> 4
        right:	2 -> 2
        fourth:	3 -> 8
        fifth:	4 -> 16
         */
        if (event.button !== undefined) { // old safari on mac has no buttons. remove eventually.
            return [1,4,2,8,16][event.button];
        }
        return 0;
    }

    const correctedObj: ICorrectedPointerEvent = {
        pointerId: event.pointerId,
        pointerType: event.pointerType,
        pageX: event.pageX,
        pageY: event.pageY,
        clientX: event.clientX,
        clientY: event.clientY,
        movementX: event.movementX,
        movementY: event.movementY,
        timeStamp: event.timeStamp + timeStampOffset,
        pressure: pressureNormalizer.normalize(event.pressure),
        buttons: determineButtons(),
        button: event.button,
        coalescedArr: [],
        eventPreventDefault: () => event.preventDefault(),
        eventStopPropagation: () => event.stopPropagation(),
    };
    (event as TExtendedDOMPointerEvent).corrected = correctedObj;

    let customPressure = null;
    if ('pointerId' in event) {
        if ('pressure' in event && event.buttons !== 0 && (['mouse'].includes(event.pointerType) || (event.pointerType === 'touch' && event.pressure === 0))) {
            correctedObj.pressure = 1;
            customPressure = 1;
        }
    } else {
        correctedObj.pointerId = 0;
        correctedObj.pointerType = 'mouse';
        correctedObj.pressure = (event as PointerEvent).buttons !== 0 ? 1 : 0;
        customPressure = correctedObj.pressure;
    }

    if (isFirefox && event.pointerType != 'mouse' && event.type === 'pointermove' && event.buttons === 0) { // once again firefox
        correctedObj.buttons = 1; // todo wrong if no buttons actually pressed
    }

    let coalescedEventArr: PointerEvent[] = [];
    if ('getCoalescedEvents' in event) {
        coalescedEventArr = event.getCoalescedEvents();
    }

    // chrome somehow movementX not same scale as pageX. todo: only chrome?
    // so make my own

    const pointerObj: IPointer = getPointer(correctedObj) || addPointer(correctedObj);

    const totalLastX = pointerObj.lastPageX;
    const totalLastY = pointerObj.lastPageY;

    for (let i = 0; i < coalescedEventArr.length; i++) {
        const eventItem = coalescedEventArr[i];

        correctedObj.coalescedArr.push({
            pageX: eventItem.pageX,
            pageY: eventItem.pageY,
            clientX: eventItem.clientX,
            clientY: eventItem.clientY,
            movementX: pointerObj.lastPageX === null ? 0 : eventItem.pageX - pointerObj.lastPageX,
            movementY: pointerObj.lastPageY === null ? 0 : eventItem.pageY - pointerObj.lastPageY,
            timeStamp: eventItem.timeStamp === 0 ? correctedObj.timeStamp : (eventItem.timeStamp + timeStampOffset), // 0 in firefox
            pressure: customPressure === null ? pressureNormalizer.normalize(eventItem.pressure) : customPressure,
        });

        pointerObj.lastPageX = eventItem.pageX;
        pointerObj.lastPageY = eventItem.pageY;
    }

    pointerObj.lastPageX = correctedObj.pageX;
    pointerObj.lastPageY = correctedObj.pageY;
    correctedObj.movementX = totalLastX === null ? 0 : pointerObj.lastPageX - totalLastX;
    correctedObj.movementY = totalLastY === null ? 0 : pointerObj.lastPageY - totalLastY;

    return correctedObj;
}

/**
 * PointerListener - for pointer events, wheel events. uses fallbacks. ideally consistent behavior across browsers.
 * Has some workarounds for browser specific bugs. As browsers evolve this constructor should get smaller.
 */
export class PointerListener {

    private isDestroyed: boolean = false;

    // ts has problems with (HTMLElement|SVGElement) when adding event listeners
    // https://github.com/microsoft/TypeScript/issues/46819
    private readonly targetElement: HTMLElement;
    private readonly onPointerCallback: undefined | ((pointerEvent: IPointerEvent) => void);
    private readonly onWheelCallback: undefined | ((wheelEvent: IWheelEvent) => void);
    private readonly onEnterLeaveCallback: undefined | ((isOver: boolean) => void);
    private readonly maxPointers: number;
    private readonly wheelCleaner: WheelCleaner;
    private isOverCounter: number = 0;

    // pointers that are pressing a button
    private dragObjArr: IDragObj[] = [];
    private dragPointerIdArr: number[] = [];

    // chrome input glitch workaround
    private lastPointerType: TPointerType | null = null;
    private didSkip: boolean = false;

    // listeners
    private readonly onPointerEnter: (() => void) | undefined;
    private readonly onPointerLeave: (() => void) | undefined;
    private readonly onPointerMove: ((event: PointerEvent) => void) | undefined;
    private readonly onPointerDown: ((event: PointerEvent, skipGlobal?: boolean) => void) | undefined;
    private readonly onWheel: ((e: WheelEvent) => void) | undefined;
    private readonly onTouchMoveScribbleFix: ((e: TouchEvent) => void) | undefined;
    private readonly windowOnPointerMove: ((event: PointerEvent) => void) | undefined;
    private readonly windowOnPointerUp: ((event: PointerEvent) => void) | undefined;
    private readonly windowOnPointerLeave: ((event: PointerEvent) => void) | undefined;
    // fallback pre pointer events (iOS < 13, as of 2023-02, still 4.4% of iOS users)
    private readonly onTouchStart: ((e: TouchEvent) => void) | undefined;
    private readonly onTouchMove: ((e: TouchEvent) => void) | undefined;
    private readonly onTouchEnd: ((e: TouchEvent) => void) | undefined;
    private readonly onTouchCancel: ((e: TouchEvent) => void) | undefined;

    private getDragObj (pointerId: number): IDragObj | null {
        for (let i = 0; i < this.dragObjArr.length; i++) {
            if (pointerId === this.dragObjArr[i].pointerId) {
                return this.dragObjArr[i];
            }
        }
        return null;
    }

    private removeDragObj (pointerId: number): IDragObj | null {
        let removedDragObj: IDragObj | null = null;
        for (let i = 0; i < this.dragPointerIdArr.length; i++) {
            if (this.dragPointerIdArr[i] === pointerId) {
                removedDragObj = this.dragObjArr[i];
                this.dragObjArr.splice(i, 1);
                this.dragPointerIdArr.splice(i, 1);
                i--;
            }
        }
        return removedDragObj;
    }

    /**
     * Creates a value for onPointer, from a pointer event handler.
     */
    private createPointerOutEvent (
        typeStr: TPointerEventType,
        correctedEvent: ICorrectedPointerEvent,
        custom?: Partial<IPointerEvent>,
    ): IPointerEvent {

        const bounds: DOMRect = this.targetElement.getBoundingClientRect();
        const result: IPointerEvent = {
            type: typeStr,
            pointerId: correctedEvent.pointerId,
            pointerType: correctedEvent.pointerType as TPointerType,
            pageX: correctedEvent.pageX,
            pageY: correctedEvent.pageY,
            clientX: correctedEvent.clientX,
            clientY: correctedEvent.clientY,
            relX: correctedEvent.clientX - bounds.left + this.targetElement.scrollLeft,
            relY: correctedEvent.clientY - bounds.top + this.targetElement.scrollTop,
            dX: correctedEvent.movementX,
            dY: correctedEvent.movementY,
            time: correctedEvent.timeStamp,
            eventPreventDefault: correctedEvent.eventPreventDefault,
            eventStopPropagation: correctedEvent.eventStopPropagation,
            ...custom,
        };

        if (typeStr === 'pointermove') {
            result.coalescedArr = [];
            if (correctedEvent.coalescedArr.length > 1) {
                let coalescedItem;
                for (let i = 0; i < correctedEvent.coalescedArr.length; i++) {
                    coalescedItem = correctedEvent.coalescedArr[i];
                    result.coalescedArr.push({
                        pageX: coalescedItem.pageX,
                        pageY: coalescedItem.pageY,
                        clientX: coalescedItem.clientX,
                        clientY: coalescedItem.clientY,
                        relX: coalescedItem.clientX - bounds.left + this.targetElement.scrollLeft,
                        relY: coalescedItem.clientY - bounds.top + this.targetElement.scrollTop,
                        dX: coalescedItem.movementX,
                        dY: coalescedItem.movementY,
                        time: coalescedItem.timeStamp,
                    });
                }
            }
        }

        return result;
    }

    private setupDocumentListeners () {
        this.windowOnPointerMove && document.addEventListener(pointerMoveEvt, this.windowOnPointerMove);
        this.windowOnPointerUp && document.addEventListener(pointerUpEvt, this.windowOnPointerUp);
        this.windowOnPointerLeave && document.addEventListener(pointerCancelEvt, this.windowOnPointerLeave);
        this.windowOnPointerLeave && document.addEventListener(pointerLeaveEvt, this.windowOnPointerLeave);
    }

    private destroyDocumentListeners () {
        this.windowOnPointerMove && document.removeEventListener(pointerMoveEvt, this.windowOnPointerMove);
        this.windowOnPointerUp && document.removeEventListener(pointerUpEvt, this.windowOnPointerUp);
        this.windowOnPointerLeave && document.removeEventListener(pointerCancelEvt, this.windowOnPointerLeave);
        this.windowOnPointerLeave && document.removeEventListener(pointerLeaveEvt, this.windowOnPointerLeave);
    }

    // ---- public ----

    constructor (p: IPointerListenerParams) {
        this.targetElement = p.target as HTMLElement;
        this.onPointerCallback = p.onPointer;
        this.onWheelCallback = p.onWheel;
        this.onEnterLeaveCallback = p.onEnterLeave;
        this.maxPointers = Math.max(1, p.maxPointers || 1);

        this.wheelCleaner = new WheelCleaner((cleanerEvent) => {
            if (this.isDestroyed || !this.onWheelCallback) {
                return;
            }
            const bounds = this.targetElement.getBoundingClientRect();
            const whlEvent = {
                ...cleanerEvent,
                relX: cleanerEvent.clientX - bounds.left + this.targetElement.scrollLeft,
                relY: cleanerEvent.clientY - bounds.top + this.targetElement.scrollTop,
            };
            this.onWheelCallback(whlEvent);
        });

        if (this.onPointerCallback) {
            this.onPointerMove = (event: PointerEvent) => {
                const correctedEvent = correctPointerEvent(event);

                const tempLastPointerType = this.lastPointerType;
                this.lastPointerType = correctedEvent.pointerType as TPointerType;

                if (
                    this.dragPointerIdArr.includes(correctedEvent.pointerId) ||
                    this.dragPointerIdArr.length === this.maxPointers ||
                    correctedEvent.pointerType === 'touch'
                ) {
                    this.didSkip = false;
                    return;
                }

                // chrome input glitch workaround - throws in a random mouse event with the wrong position when using a stylus
                if (!this.didSkip && correctedEvent.pointerType === 'mouse' && tempLastPointerType === 'pen') {
                    this.didSkip = true;
                    return;
                }
                this.didSkip = false;

                const outEvent = this.createPointerOutEvent('pointermove', correctedEvent);
                this.onPointerCallback && this.onPointerCallback(outEvent);
            };

            this.onPointerDown = (event: PointerEvent, onSkipGlobal? : boolean) => {

                //BB.throwOut('pointerdown ' + event.pointerId + ' | ' + dragPointerIdArr.length);
                const correctedEvent = correctPointerEvent(event);
                ////console.log('debug: ' + event.pointerId + ' pointerdown');
                if (this.dragPointerIdArr.includes(correctedEvent.pointerId) || this.dragPointerIdArr.length === this.maxPointers || ![1, 2, 4].includes(correctedEvent.buttons)) {
                    //BB.throwOut('pointerdown ignored');
                    return;
                }

                //set up global listeners
                if (this.dragObjArr.length === 0 && !onSkipGlobal) {
                    this.setupDocumentListeners();
                }
                const dragObj: IDragObj = {
                    pointerId: correctedEvent.pointerId,
                    pointerType: correctedEvent.pointerType as TPointerType,
                    downPageX: correctedEvent.pageX,
                    downPageY: correctedEvent.pageY,
                    buttons: correctedEvent.buttons,
                    lastPageX: correctedEvent.pageX,
                    lastPageY: correctedEvent.pageY,
                    lastTimeStamp: correctedEvent.timeStamp,
                };
                this.dragObjArr.push(dragObj);
                this.dragPointerIdArr.push(correctedEvent.pointerId);

                const outEvent: IPointerEvent = this.createPointerOutEvent('pointerdown', correctedEvent, {
                    downPageX: correctedEvent.pageX,
                    downPageY: correctedEvent.pageY,
                    button: getButtonStr(correctedEvent.buttons),
                    pressure: correctedEvent.pressure,
                });

                this.onPointerCallback && this.onPointerCallback(outEvent);
            };

            this.windowOnPointerMove = (event: PointerEvent) => {
                //BB.throwOut('pointermove ' + event.pointerId);
                const correctedEvent = correctPointerEvent(event);
                ////console.log('debug: ' + event.pointerId + ' GLOBALpointermove');
                if (!(this.dragPointerIdArr.includes(correctedEvent.pointerId))) {
                    return;
                }

                const dragObj = this.getDragObj(correctedEvent.pointerId);

                if (!dragObj) {
                    // todo need to handle this!
                    return;
                }

                //if pointer changes button its pressing -> turn into pointerup
                if (correctedEvent.buttons !== dragObj.buttons) {
                    //pointer up

                    //remove listener
                    if (this.dragObjArr.length === 1) {
                        this.destroyDocumentListeners();
                    }
                    this.removeDragObj(correctedEvent.pointerId);

                    const outEvent = this.createPointerOutEvent('pointerup', correctedEvent, {
                        downPageX: dragObj.downPageX,
                        downPageY: dragObj.downPageY,
                    });
                    this.onPointerCallback && this.onPointerCallback(outEvent);
                    return;
                }

                // ipad likes to do this
                if (
                    correctedEvent.pointerType === 'pen' &&
                    correctedEvent.pageX === dragObj.lastPageX &&
                    correctedEvent.pageY === dragObj.lastPageY &&
                    correctedEvent.timeStamp === dragObj.lastTimeStamp
                ) {
                    //ignore
                    return;
                }

                const outEvent = this.createPointerOutEvent('pointermove', correctedEvent, {
                    downPageX: dragObj.downPageX,
                    downPageY: dragObj.downPageY,
                    button: getButtonStr(correctedEvent.buttons),
                    pressure: correctedEvent.pressure,
                });

                dragObj.lastPageX = correctedEvent.pageX;
                dragObj.lastPageY = correctedEvent.pageY;
                dragObj.lastTimeStamp = correctedEvent.timeStamp;

                this.onPointerCallback && this.onPointerCallback(outEvent);
            };

            this.windowOnPointerUp = (event: PointerEvent) => {
                //BB.throwOut('pointerup ' + event.pointerId);
                const correctedEvent = correctPointerEvent(event);
                ////console.log('debug: ' + event.pointerId + ' GLOBALpointerup');
                if (!(this.dragPointerIdArr.includes(correctedEvent.pointerId))) {
                    return;
                }

                //remove listener
                if (this.dragObjArr.length === 1) {
                    this.destroyDocumentListeners();
                }
                const dragObj = this.removeDragObj(correctedEvent.pointerId);
                if (!dragObj) {
                    // todo need to handle this!
                    return;
                }

                const outEvent = this.createPointerOutEvent('pointerup', correctedEvent, {
                    downPageX: dragObj.downPageX,
                    downPageY: dragObj.downPageY,
                });
                this.onPointerCallback && this.onPointerCallback(outEvent);

            };

            this.windowOnPointerLeave = (event: PointerEvent) => {
                //BB.throwOut('pointerleave ' + event.pointerId);
                const correctedEvent = correctPointerEvent(event);
                ////console.log('debug: ' + event.pointerId + ' onGlobalPointerLeave', event);
                if (!(this.dragPointerIdArr.includes(correctedEvent.pointerId))) { //} || event.target !== document) {
                    return;
                }

                //remove listener
                if (this.dragObjArr.length === 1) {
                    this.destroyDocumentListeners();
                }
                const dragObj = this.removeDragObj(correctedEvent.pointerId);
                if (!dragObj) {
                    // todo need to handle this!
                    return;
                }

                const outEvent = this.createPointerOutEvent('pointerup', correctedEvent, {
                    downPageX: dragObj.downPageX,
                    downPageY: dragObj.downPageY,
                });
                this.onPointerCallback && this.onPointerCallback(outEvent);
            };

            this.targetElement.addEventListener(pointerMoveEvt, this.onPointerMove);
            this.targetElement.addEventListener(pointerDownEvt, this.onPointerDown);

            if (!hasPointerEvents) {
                const touchToFakePointer = (touch: Touch, touchEvent: TouchEvent, isDown: boolean) => {
                    return {
                        pointerId: touch.identifier,
                        pointerType: 'touch',
                        pageX: touch.pageX,
                        pageY: touch.pageY,
                        clientX: touch.clientX,
                        clientY: touch.clientY,
                        button: isDown ? 0 : undefined,
                        buttons: isDown ? 1 : 0,
                        timeStamp: touchEvent.timeStamp,
                        target: touchEvent.target,
                        pressure: isDown ? 1 : 0,
                        preventDefault: () => touchEvent.preventDefault(),
                        stopPropagation: () => touchEvent.stopPropagation(),
                    };
                };

                const handleTouch = (e: TouchEvent, type: 'start' | 'move' | 'end' | 'cancel'): void => {
                    for (let i = 0; i < e.changedTouches.length; i++) {
                        const touch = e.changedTouches[i];
                        const fakePointer = touchToFakePointer(touch, e, ['start', 'move'].includes(type));
                        if (type === 'start') {
                            this.onPointerDown!(fakePointer as PointerEvent, false);
                        } else if (type === 'move') {
                            this.windowOnPointerMove!(fakePointer as PointerEvent);
                        } else if (type === 'end') {
                            this.windowOnPointerUp!(fakePointer as PointerEvent);
                        } else {
                            this.windowOnPointerLeave!(fakePointer as PointerEvent);
                        }
                    }
                };

                this.onTouchStart = (e: TouchEvent): void => {
                    e.preventDefault();
                    handleTouch(e, 'start');
                };
                this.onTouchMove = (e: TouchEvent): void => {
                    handleTouch(e, 'move');
                };
                this.onTouchEnd = (e: TouchEvent): void => {
                    handleTouch(e, 'end');
                };
                this.onTouchCancel = (e: TouchEvent): void => {
                    handleTouch(e, 'cancel');
                };

                this.targetElement.addEventListener('touchstart', this.onTouchStart);
                this.targetElement.addEventListener('touchmove', this.onTouchMove);
                this.targetElement.addEventListener('touchend', this.onTouchEnd);
                this.targetElement.addEventListener('touchcancel', this.onTouchCancel);
            }
        }
        if (this.onWheelCallback) {
            this.onWheel = (e: WheelEvent) => {
                this.wheelCleaner.process(e);
            };
            this.targetElement.addEventListener('wheel', this.onWheel);
        }
        if (this.onEnterLeaveCallback) {
            this.onPointerEnter = () => {
                this.isOverCounter++;
                this.onEnterLeaveCallback && this.onEnterLeaveCallback(true);
            };

            this.onPointerLeave = () => {
                this.isOverCounter--;
                this.onEnterLeaveCallback && this.onEnterLeaveCallback(false);
            };

            this.targetElement.addEventListener(pointerEnterEvt, this.onPointerEnter);
            this.targetElement.addEventListener(pointerLeaveEvt, this.onPointerLeave);
        }

        if (p.fixScribble) {
            //ipad scribble workaround https://developer.apple.com/forums/thread/662874
            this.onTouchMoveScribbleFix = (e: TouchEvent) => e.preventDefault();
            this.targetElement.addEventListener('touchmove', this.onTouchMoveScribbleFix);
        }
    }

    destroy (): void {
        if (this.isDestroyed) {
            return;
        }
        this.isDestroyed = true;
        this.onPointerEnter && this.targetElement.removeEventListener(pointerEnterEvt, this.onPointerEnter);
        this.onPointerLeave && this.targetElement.removeEventListener(pointerLeaveEvt, this.onPointerLeave);
        this.onPointerMove && this.targetElement.removeEventListener(pointerMoveEvt, this.onPointerMove);
        this.onPointerDown && this.targetElement.removeEventListener(pointerDownEvt, this.onPointerDown);
        this.onWheel && this.targetElement.removeEventListener('wheel', this.onWheel);
        this.destroyDocumentListeners();
        this.onTouchMoveScribbleFix && document.removeEventListener('touchmove', this.onTouchMoveScribbleFix);

        this.onTouchStart && this.targetElement.removeEventListener('touchstart', this.onTouchStart);
        this.onTouchMove && this.targetElement.removeEventListener('touchmove', this.onTouchMove);
        this.onTouchEnd && this.targetElement.removeEventListener('touchend', this.onTouchEnd);
        this.onTouchCancel && this.targetElement.removeEventListener('touchcancel', this.onTouchCancel);
    }

}


code:
import {mix} from '../math/math';

/**
 * different devices/browsers have different pressure curves
 * pressure normalize attempts to achieve similar mapping
 *
 * pressure range [0, 1]
 */
export class PressureNormalizer {

    private count: number = 0;
    private avgPressure: number = 0; // float [0, 1]
    private normalizeIsComplete: boolean = false;
    private normalizeFactor: number = 1;

    // ---- public ----
    normalize (pressure: number): number {
        if (pressure === 0 || pressure === 1) {
            return pressure;
        }

        if (this.count < 60) {
            if (this.count === 0 || this.count === null) {
                this.avgPressure = pressure;
            } else {
                this.avgPressure = mix(pressure, this.avgPressure, 0.95);
            }
            this.count++;
        } else if (!this.normalizeIsComplete) {
            this.normalizeIsComplete = true;
            //BB.throwOut('avg pressure decision!' + this.avgPressure);
            if (this.avgPressure < 0.13) { // absurd pressure needed
                this.normalizeFactor = 2.3;
            }
        }

        return Math.pow(pressure, 1 / this.normalizeFactor);
    }

}