Eventful.ts 9.27 KB
import { Dictionary, WithThisType } from './types';

// Return true to cancel bubble
export type EventCallbackSingleParam<EvtParam = any> = EvtParam extends any
    ? (params: EvtParam) => boolean | void
    : never

export type EventCallback<EvtParams = any[]> = EvtParams extends any[]
    ? (...args: EvtParams) => boolean | void
    : never
export type EventQuery = string | Object

type CbThis<Ctx, Impl> = unknown extends Ctx ? Impl : Ctx;

type EventHandler<Ctx, Impl, EvtParams> = {
    h: EventCallback<EvtParams>
    ctx: CbThis<Ctx, Impl>
    query: EventQuery

    callAtLast: boolean
}

type DefaultEventDefinition = Dictionary<EventCallback<any[]>>;

export interface EventProcessor<EvtDef = DefaultEventDefinition> {
    normalizeQuery?: (query: EventQuery) => EventQuery
    filter?: (eventType: keyof EvtDef, query: EventQuery) => boolean
    afterTrigger?: (eventType: keyof EvtDef) => void
}

/**
 * Event dispatcher.
 *
 * Event can be defined in EvtDef to enable type check. For example:
 * ```ts
 * interface FooEvents {
 *     // key: event name, value: the first event param in `trigger` and `callback`.
 *     myevent: {
 *        aa: string;
 *        bb: number;
 *     };
 * }
 * class Foo extends Eventful<FooEvents> {
 *     fn() {
 *         // Type check of event name and the first event param is enabled here.
 *         this.trigger('myevent', {aa: 'xx', bb: 3});
 *     }
 * }
 * let foo = new Foo();
 * // Type check of event name and the first event param is enabled here.
 * foo.on('myevent', (eventParam) => { ... });
 * ```
 *
 * @param eventProcessor The object eventProcessor is the scope when
 *        `eventProcessor.xxx` called.
 * @param eventProcessor.normalizeQuery
 *        param: {string|Object} Raw query.
 *        return: {string|Object} Normalized query.
 * @param eventProcessor.filter Event will be dispatched only
 *        if it returns `true`.
 *        param: {string} eventType
 *        param: {string|Object} query
 *        return: {boolean}
 * @param eventProcessor.afterTrigger Called after all handlers called.
 *        param: {string} eventType
 */
export default class Eventful<EvtDef extends DefaultEventDefinition = DefaultEventDefinition> {

    private _$handlers: Dictionary<EventHandler<any, any, any[]>[]>

    protected _$eventProcessor: EventProcessor<EvtDef>

    constructor(eventProcessors?: EventProcessor<EvtDef>) {
        if (eventProcessors) {
            this._$eventProcessor = eventProcessors;
        }
    }

    on<Ctx, EvtNm extends keyof EvtDef>(
        event: EvtNm,
        handler: WithThisType<EvtDef[EvtNm], CbThis<Ctx, this>>,
        context?: Ctx
    ): this
    on<Ctx, EvtNm extends keyof EvtDef>(
        event: EvtNm,
        query: EventQuery,
        handler: WithThisType<EvtDef[EvtNm], CbThis<Ctx, this>>,
        context?: Ctx
    ): this
    /**
     * Bind a handler.
     *
     * @param event The event name.
     * @param Condition used on event filter.
     * @param handler The event handler.
     * @param context
     */
    on<Ctx, EvtNm extends keyof EvtDef>(
        event: EvtNm,
        query: EventQuery | WithThisType<EventCallback<EvtDef[EvtNm]>, CbThis<Ctx, this>>,
        handler?: WithThisType<EventCallback<EvtDef[EvtNm]>, CbThis<Ctx, this>> | Ctx,
        context?: Ctx
    ): this {
        if (!this._$handlers) {
            this._$handlers = {};
        }

        const _h = this._$handlers;

        if (typeof query === 'function') {
            context = handler as Ctx;
            handler = query as (...args: any) => any;
            query = null;
        }

        if (!handler || !event) {
            return this;
        }

        const eventProcessor = this._$eventProcessor;
        if (query != null && eventProcessor && eventProcessor.normalizeQuery) {
            query = eventProcessor.normalizeQuery(query);
        }

        if (!_h[event as string]) {
            _h[event as string] = [];
        }

        for (let i = 0; i < _h[event as string].length; i++) {
            if (_h[event as string][i].h === handler) {
                return this;
            }
        }

        const wrap: EventHandler<Ctx, this, unknown[]> = {
            h: handler as EventCallback<unknown[]>,
            query: query,
            ctx: (context || this) as CbThis<Ctx, this>,
            // FIXME
            // Do not publish this feature util it is proved that it makes sense.
            callAtLast: (handler as any).zrEventfulCallAtLast
        };

        const lastIndex = _h[event as string].length - 1;
        const lastWrap = _h[event as string][lastIndex];
        (lastWrap && lastWrap.callAtLast)
            ? _h[event as string].splice(lastIndex, 0, wrap)
            : _h[event as string].push(wrap);

        return this;
    }

    /**
     * Whether any handler has bound.
     */
    isSilent(eventName: keyof EvtDef): boolean {
        const _h = this._$handlers;
        return !_h || !_h[eventName as string] || !_h[eventName as string].length;
    }

    /**
     * Unbind a event.
     *
     * @param eventType The event name.
     *        If no `event` input, "off" all listeners.
     * @param handler The event handler.
     *        If no `handler` input, "off" all listeners of the `event`.
     */
    off(eventType?: keyof EvtDef, handler?: Function): this {
        const _h = this._$handlers;

        if (!_h) {
            return this;
        }

        if (!eventType) {
            this._$handlers = {};
            return this;
        }

        if (handler) {
            if (_h[eventType as string]) {
                const newList = [];
                for (let i = 0, l = _h[eventType as string].length; i < l; i++) {
                    if (_h[eventType as string][i].h !== handler) {
                        newList.push(_h[eventType as string][i]);
                    }
                }
                _h[eventType as string] = newList;
            }

            if (_h[eventType as string] && _h[eventType as string].length === 0) {
                delete _h[eventType as string];
            }
        }
        else {
            delete _h[eventType as string];
        }

        return this;
    }

    /**
     * Dispatch a event.
     *
     * @param {string} eventType The event name.
     */
    trigger<EvtNm extends keyof EvtDef>(
        eventType: EvtNm,
        ...args: Parameters<EvtDef[EvtNm]>
    ): this {
        if (!this._$handlers) {
            return this;
        }

        const _h = this._$handlers[eventType as string];
        const eventProcessor = this._$eventProcessor;

        if (_h) {
            const argLen = args.length;

            const len = _h.length;
            for (let i = 0; i < len; i++) {
                const hItem = _h[i];
                if (eventProcessor
                    && eventProcessor.filter
                    && hItem.query != null
                    && !eventProcessor.filter(eventType, hItem.query)
                ) {
                    continue;
                }

                // Optimize advise from backbone
                switch (argLen) {
                    case 0:
                        hItem.h.call(hItem.ctx);
                        break;
                    case 1:
                        hItem.h.call(hItem.ctx, args[0]);
                        break;
                    case 2:
                        hItem.h.call(hItem.ctx, args[0], args[1]);
                        break;
                    default:
                        // have more than 2 given arguments
                        hItem.h.apply(hItem.ctx, args);
                        break;
                }
            }
        }

        eventProcessor && eventProcessor.afterTrigger
            && eventProcessor.afterTrigger(eventType);

        return this;
    }

    /**
     * Dispatch a event with context, which is specified at the last parameter.
     *
     * @param {string} type The event name.
     */
    triggerWithContext(type: keyof EvtDef, ...args: any[]): this {
        if (!this._$handlers) {
            return this;
        }

        const _h = this._$handlers[type as string];
        const eventProcessor = this._$eventProcessor;

        if (_h) {
            const argLen = args.length;
            const ctx = args[argLen - 1];

            const len = _h.length;
            for (let i = 0; i < len; i++) {
                const hItem = _h[i];
                if (eventProcessor
                    && eventProcessor.filter
                    && hItem.query != null
                    && !eventProcessor.filter(type, hItem.query)
                ) {
                    continue;
                }

                // Optimize advise from backbone
                switch (argLen) {
                    case 0:
                        hItem.h.call(ctx);
                        break;
                    case 1:
                        hItem.h.call(ctx, args[0]);
                        break;
                    case 2:
                        hItem.h.call(ctx, args[0], args[1]);
                        break;
                    default:
                        // have more than 2 given arguments
                        hItem.h.apply(ctx, args.slice(1, argLen - 1));
                        break;
                }
            }
        }

        eventProcessor && eventProcessor.afterTrigger
            && eventProcessor.afterTrigger(type);

        return this;
    }

}