import { Arr, Fun, Obj } from '@ephox/katamari'; import * as EventUtils from '../../events/EventUtils'; import Tools from './Tools'; export type MappedEvent = K extends keyof T ? T[K] : any; export interface NativeEventMap { 'beforepaste': Event; 'blur': FocusEvent; 'beforeinput': InputEvent; 'click': MouseEvent; 'compositionend': Event; 'compositionstart': Event; 'compositionupdate': Event; 'contextmenu': PointerEvent; 'copy': ClipboardEvent; 'cut': ClipboardEvent; 'dblclick': MouseEvent; 'drag': DragEvent; 'dragdrop': DragEvent; 'dragend': DragEvent; 'draggesture': DragEvent; 'dragover': DragEvent; 'dragstart': DragEvent; 'drop': DragEvent; 'focus': FocusEvent; 'focusin': FocusEvent; 'focusout': FocusEvent; 'input': InputEvent; 'keydown': KeyboardEvent; 'keypress': KeyboardEvent; 'keyup': KeyboardEvent; 'mousedown': MouseEvent; 'mouseenter': MouseEvent; 'mouseleave': MouseEvent; 'mousemove': MouseEvent; 'mouseout': MouseEvent; 'mouseover': MouseEvent; 'mouseup': MouseEvent; 'paste': ClipboardEvent; 'selectionchange': Event; 'submit': Event; 'touchend': TouchEvent; 'touchmove': TouchEvent; 'touchstart': TouchEvent; 'touchcancel': TouchEvent; 'wheel': WheelEvent; } export type EditorEvent = EventUtils.NormalizedEvent; export interface EventDispatcherSettings { scope?: any; toggleEvent?: (name: string, state: boolean) => void | boolean; beforeFire?: (args: EditorEvent) => void; } export interface EventDispatcherConstructor { readonly prototype: EventDispatcher; new (settings?: EventDispatcherSettings): EventDispatcher; isNative: (name: string) => boolean; } /** * This class lets you add/remove and dispatch events by name on the specified scope. This makes * it easy to add event listener logic to any class. * * @class tinymce.util.EventDispatcher * @example * const eventDispatcher = new EventDispatcher(); * * eventDispatcher.on('click', () => console.log('data')); * eventDispatcher.dispatch('click', { data: 123 }); */ const nativeEvents = Tools.makeMap( 'focus blur focusin focusout click dblclick mousedown mouseup mousemove mouseover beforepaste paste cut copy selectionchange ' + 'mouseout mouseenter mouseleave wheel keydown keypress keyup input beforeinput contextmenu dragstart dragend dragover ' + 'draggesture dragdrop drop drag submit ' + 'compositionstart compositionend compositionupdate touchstart touchmove touchend touchcancel', ' ' ); interface Binding { func: (event: EditorEvent>) => void | boolean; removed: boolean; once?: true; } type Bindings = { [K in string]?: Binding[]; }; class EventDispatcher { /** * Returns true/false if the specified event name is a native browser event or not. * * @method isNative * @param {String} name Name to check if it's native. * @return {Boolean} true/false if the event is native or not. * @static */ public static isNative(name: string): boolean { return !!nativeEvents[name.toLowerCase()]; } private readonly settings: EventDispatcherSettings; private readonly scope: any; private readonly toggleEvent: (name: string, toggle: boolean) => void; private bindings: Bindings = {}; public constructor(settings?: EventDispatcherSettings) { this.settings = settings || {}; this.scope = this.settings.scope || this; this.toggleEvent = this.settings.toggleEvent || Fun.never; } /** * Fires the specified event by name. *
* Deprecated in TinyMCE 6.0 and has been marked for removal in TinyMCE 7.0. Use dispatch instead. * * @method fire * @param {String} name Name of the event to fire. * @param {Object?} args Event arguments. * @return {Object} Event args instance passed in. * @deprecated Use dispatch() instead * @example * instance.fire('event', {...}); */ public fire >(name: K, args?: U): EditorEvent { return this.dispatch(name, args); } /** * Dispatches the specified event by name. * * @method dispatch * @param {String} name Name of the event to dispatch * @param {Object?} args Event arguments. * @return {Object} Event args instance passed in. * @example * instance.dispatch('event', {...}); */ public dispatch >(name: K, args?: U): EditorEvent { const lcName = name.toLowerCase(); const event = EventUtils.normalize(lcName, args ?? {}, this.scope) as EventUtils.NormalizedEvent; if (this.settings.beforeFire) { this.settings.beforeFire(event); } // Don't clone the array here as this is a hot code path, so instead the handlers // array is recreated and the this.bindings[name] reference is updated in the `on` // and `off` functions. This is done to avoid the handlers array being mutated while // we're iterating over it below. const handlers = this.bindings[lcName]; if (handlers) { for (let i = 0, l = handlers.length; i < l; i++) { const callback = handlers[i]; // The handler was removed by an earlier handler in this loop so skip it. if (callback.removed) { continue; } // Unbind handlers marked with "once" if (callback.once) { this.off(lcName, callback.func); } // Stop immediate propagation if needed if (event.isImmediatePropagationStopped()) { return event; } // If callback returns false then prevent default and stop all propagation if (callback.func.call(this.scope, event) === false) { event.preventDefault(); return event; } } } return event; } /** * Binds an event listener to a specific event by name. * * @method on * @param {String} name Event name or space separated list of events to bind. * @param {Function} callback Callback to be executed when the event occurs. * @param {Boolean} prepend Optional flag if the event should be prepended. Use this with care. * @return {Object} Current class instance. * @example * instance.on('event', (e) => { * // Callback logic * }); */ public on (name: K, callback: false | ((event: EditorEvent>) => void | boolean), prepend?: boolean, extra?: {}): this { if (callback === false) { callback = Fun.never; } if (callback) { const wrappedCallback: Binding = { func: callback as (event: EditorEvent>) => void | boolean, removed: false }; if (extra) { Tools.extend(wrappedCallback, extra); } const names = name.toLowerCase().split(' '); let i = names.length; while (i--) { const currentName = names[i]; let handlers = this.bindings[currentName]; if (!handlers) { handlers = []; this.toggleEvent(currentName, true); } if (prepend) { handlers = [ wrappedCallback, ...handlers ]; } else { handlers = [ ...handlers, wrappedCallback ]; } this.bindings[currentName] = handlers; } } return this; } /** * Unbinds an event listener to a specific event by name. * * @method off * @param {String?} name Name of the event to unbind. * @param {Function?} callback Callback to unbind. * @return {Object} Current class instance. * @example * // Unbind specific callback * instance.off('event', handler); * * // Unbind all listeners by name * instance.off('event'); * * // Unbind all events * instance.off(); */ public off (name?: K, callback?: (event: EditorEvent>) => void): this { if (name) { const names = name.toLowerCase().split(' '); let i = names.length; while (i--) { const currentName = names[i]; let handlers = this.bindings[currentName]; // Unbind all handlers if (!currentName) { Obj.each(this.bindings, (_value, bindingName) => { this.toggleEvent(bindingName, false); delete this.bindings[bindingName]; }); return this; } if (handlers) { // Unbind all by name if (!callback) { handlers.length = 0; } else { // Unbind specific handlers const filteredHandlers = Arr.partition(handlers, (handler) => handler.func === callback); handlers = filteredHandlers.fail; this.bindings[currentName] = handlers; // Mark the removed handlers in case this event is already being processed in `fire` Arr.each(filteredHandlers.pass, (handler) => { handler.removed = true; }); } if (!handlers.length) { this.toggleEvent(name, false); delete this.bindings[currentName]; } } } } else { Obj.each(this.bindings, (_value, name) => { this.toggleEvent(name, false); }); this.bindings = {}; } return this; } /** * Binds an event listener to a specific event by name * and automatically unbind the event once the callback fires. * * @method once * @param {String} name Event name or space separated list of events to bind. * @param {Function} callback Callback to be executed when the event occurs. * @param {Boolean} prepend Optional flag if the event should be prepended. Use this with care. * @return {Object} Current class instance. * @example * instance.once('event', (e) => { * // Callback logic * }); */ public once (name: K, callback: (event: EditorEvent>) => void, prepend?: boolean): this { return this.on(name, callback, prepend, { once: true }); } /** * Returns true/false if the dispatcher has a event of the specified name. * * @method has * @param {String} name Name of the event to check for. * @return {Boolean} true/false if the event exists or not. */ public has(name: string): boolean { name = name.toLowerCase(); const binding = this.bindings[name]; return !(!binding || binding.length === 0); } } export default EventDispatcher;