implements/base.js

/*
 * This file is part of AUX.
 *
 * AUX is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public
 * License as published by the Free Software Foundation; either
 * version 3 of the License, or (at your option) any later version.
 *
 * AUX is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * General Public License for more details.
 *
 * You should have received a copy of the GNU General
 * Public License along with this program; if not, write to the
 * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
 * Boston, MA  02110-1301  USA
 */

import { warn } from './../utils/log.js';
import {
  removeActiveEventListener,
  addActiveEventListener,
} from './../utils/events.js';
import {
  addEvent,
  removeEvent,
  mergeStaticEvents,
} from './../widget_helpers.js';

function callHandler(self, fun, args) {
  try {
    return fun.apply(self, args);
  } catch (e) {
    warn('event handler', fun, 'threw', e);
  }
}
function dispatchEvents(self, handlers, args) {
  let v;
  if (Array.isArray(handlers)) {
    for (let i = 0; i < handlers.length; i++) {
      v = callHandler(self, handlers[i], args);
      if (v !== void 0) return v;
    }
  } else return callHandler(self, handlers, args);
}
const __native_events = {
  // mouse
  mouseenter: true,
  mouseleave: true,
  mousedown: true,
  mouseup: true,
  mousemove: true,
  mouseover: true,

  click: true,
  dblclick: true,

  startdrag: true,
  stopdrag: true,
  drag: true,
  dragenter: true,
  dragleave: true,
  dragover: true,
  drop: true,
  dragend: true,

  // touch
  touchstart: true,
  touchend: true,
  touchmove: true,
  touchenter: true,
  touchleave: true,
  touchcancel: true,

  keydown: true,
  keypress: true,
  keyup: true,
  scroll: true,
  focus: true,
  blur: true,
  input: true,

  // mousewheel
  mousewheel: true,
  DOMMouseScroll: true,
  wheel: true,

  submit: true,
  contextmenu: true,

  // pointer events
  pointerover: true,
  pointerenter: true,
  pointerdown: true,
  pointermove: true,
  pointerup: true,
  pointercancel: true,
  pointerout: true,
  pointerleave: true,
  gotpointercapture: true,
  lostpointercapture: true,
};
export function isNativeEvent(type) {
  return __native_events[type];
}

function removeNativeEvents(element) {
  let type;
  const s = this.getStaticEvents();
  const d = this.__events;
  const handler = this.__native_handler;

  for (type in s)
    if (isNativeEvent(type)) removeActiveEventListener(element, type, handler);

  for (type in d)
    if (
      isNativeEvent(type) &&
      (!s || !Object.prototype.hasOwnProperty.call(s, type))
    )
      removeActiveEventListener(element, type, handler);
}
function addNativeEvents(element) {
  let type;
  const s = this.getStaticEvents();
  const d = this.__events;
  const handler = this.__native_handler;

  for (type in s)
    if (isNativeEvent(type)) addActiveEventListener(element, type, handler);

  for (type in d)
    if (
      isNativeEvent(type) &&
      (!s || !Object.prototype.hasOwnProperty.call(s, type))
    )
      addActiveEventListener(element, type, handler);
}
function nativeHandler(ev) {
  /* FIXME:
   * * mouseover and error are cancelled with true
   * * beforeunload is cancelled with null
   */
  if (this.emit(ev.type, ev) === false) return false;
}

function mergeOptions(...options) {
  options = options.flat();

  return Object.assign({}, ...options);
}

function hasProperty(cl, name) {
  return Object.prototype.hasOwnProperty.call(cl, name);
}

function getBaseOf(cl) {
  const prototype = Object.getPrototypeOf(cl.prototype);

  if (!prototype) return null;

  return prototype.constructor;
}

function collectFromPrototypes(cl, name, funName) {
  const result = [];
  const base = getBaseOf(cl);

  if (base && base[funName]) {
    result.push(base[funName]());
  }

  if (hasProperty(cl, name)) {
    result.push(cl[name]);
  }

  return result;
}

/**
 * This is the base class for all AUX widgets.
 * It provides an API for event handling and options.
 *
 * @class Base
 */
export class Base {
  static getOptionTypes() {
    if (!hasProperty(this, 'auxOptionTypes')) {
      const options = collectFromPrototypes(this, '_options', 'getOptionTypes');
      this.auxOptionTypes = mergeOptions(...options);
    }

    return this.auxOptionTypes;
  }

  static getOptionType(name) {
    return this.getOptionTypes()[name];
  }

  static getDefaultOptions() {
    if (!hasProperty(this, 'auxOptions')) {
      const options = collectFromPrototypes(
        this,
        'options',
        'getDefaultOptions'
      );
      this.auxOptions = mergeOptions(...options);
    }

    return this.auxOptions;
  }

  static getDefault(name) {
    return this.getDefaultOptions()[name];
  }

  static getStaticEvents() {
    if (!Object.prototype.hasOwnProperty.call(this, 'auxStaticEvents')) {
      const base = Object.getPrototypeOf(this.prototype).constructor;
      const ownEvents = Object.prototype.hasOwnProperty.call(
        this,
        'static_events'
      )
        ? this.static_events
        : {};
      let events;

      if (base.getStaticEvents) {
        events = mergeStaticEvents(base.getStaticEvents(), ownEvents);
      } else {
        events = Object.assign({}, ownEvents);
      }

      this.auxStaticEvents = events;
    }
    return this.auxStaticEvents;
  }

  static addStaticEvent(name, callback) {
    addEvent(this.getStaticEvents(), name, callback);
  }

  static defineOption(name, type, defaultValue) {
    const _options = this.getOptionTypes();
    _options[name] = type;
    if (defaultValue !== void 0) this.getDefaultOptions()[name] = defaultValue;
  }

  static hasOption(name) {
    return Object.prototype.hasOwnProperty.call(this.getOptionTypes(), name);
  }

  constructor(...args) {
    this.is_initialized = false;
    this.initialize(...args);
    this.initializeChildren();
    this.initialized();
    this.is_initialized = true;

    const element = this.getEventTarget();

    if (element !== this.__event_target) addNativeEvents.call(this, element);
  }

  getObjectEntry(optionName, value, key) {
    if (value && key in value) return value[key];

    const defaultValue = this.getDefault(optionName);

    return defaultValue ? defaultValue[key] : void 0;
  }

  initialize(options) {
    this.__events = {};
    this.__event_target = null;
    this.__native_handler = nativeHandler.bind(this);
    this.options = Object.assign({}, this.getDefaultOptions());

    for (const key in options)
      if (Object.prototype.hasOwnProperty.call(options, key)) {
        const value = options[key];

        if (key.startsWith('on')) {
          this.on(key.substring(2).toLowerCase(), options[key]);
        } else {
          this.options[key] = value;
        }
      }

    this.emit('initialize');
  }

  initializeChildren() {
    this.emit('initialize_children');
  }

  /**
   * Returns the type of an option. If the given option does not exist,
   * 'undefined' is returned.
   *
   * @method Base#getOptionType
   */
  getOptionType(name) {
    return this.constructor.getOptionType(name);
  }

  /**
   * Returns the default value of a given option. If the option does not
   * exist, an exception is thrown.
   *
   * @method Base#getDefault
   */
  getDefault(name) {
    if (this.getOptionType(name) === void 0) {
      throw new Error('Option does not exist.');
    }

    return this.constructor.getDefault(name);
  }

  getDefaultOptions() {
    return this.constructor.getDefaultOptions();
  }

  getStaticEvents() {
    return this.constructor.getStaticEvents();
  }

  initialized() {
    /**
     * Is fired when an instance is initialized.
     *
     * @event Base#initialized
     */
    this.emit('initialized');
  }

  isDestructed() {
    return this.options === null;
  }

  /**
   * Destroys all event handlers and the options object.
   *
   * @method Base#destroy
   */
  destroy() {
    const element = this.getEventTarget();

    if (element) removeNativeEvents.call(this, element);

    this.__events = null;
    this.__event_target = null;
    this.__native_handler = null;
    this.options = null;
  }

  /**
   * Get the value of an option.
   *
   * @method Base#get
   *
   * @param {string} key - The option name.
   */
  get(key) {
    return this.options[key];
  }

  /**
   * Sets an option. Fires both the events <code>set</code> with arguments <code>key</code>
   * and <code>value</code>; and the event <code>'set_'+key</code> with arguments <code>value</code>
   * and <code>key</code>.
   *
   * @method Base#set
   *
   * @param {string} key - The name of the option.
   * @param {mixed} value - The value of the option.
   *
   * @emits Base#set
   * @emits Base#set_[option]
   */
  set(key, value) {
    const options = this.options;
    const currentValue = options[key];

    options[key] = value;
    /**
     * Is fired when an option is set.
     *
     * @event Base#set
     *
     * @param {string} name - The name of the option.
     * @param {mixed} value - The value of the option.
     */
    if (this.hasEventListeners('set'))
      this.emit('set', key, value, currentValue);
    /**
     * Is fired when an option is set.
     *
     * @event Base#set_[option]
     *
     * @param {mixed} value - The value of the option.
     */
    const e = 'set_' + key;
    if (this.hasEventListeners(e)) this.emit(e, value, key, currentValue);

    return value;
  }

  /**
   * Conditionally sets an option unless it already has the requested value.
   *
   * @method Base#update
   *
   * @param {string} key - The name of the option.
   * @param {mixed} value - The value of the option.
   *
   * @emits Base#set
   * @emits Base#set_[option]
   */
  update(key, value) {
    const current_value = this.options[key];

    // If both the old and the new value are NaN there is no need to set them,
    // either.
    if (
      current_value === value ||
      (current_value !== current_value && value !== value)
    )
      return;
    this.set(key, value);
  }

  /**
   * Resets an option to its default value.
   *
   * @method Base#reset
   * @param {string} key - The option name.
   */
  reset(key) {
    return this.set(key, this.getDefault(key));
  }

  /**
   * Sets an option by user interaction. Emits the <code>userset</code>
   * event. The <code>userset</code> event can be cancelled (if an event handler
   * returns <code>false</code>), in which case the option is not set.
   * Returns <code>true</code> if the option was set, <code>false</code>
   * otherwise. If the option was set, it will emit a <code>useraction</code> event.
   *
   * @method Base#userset
   *
   * @param {string} key - The name of the option.
   * @param {mixed} value - The value of the option.
   *
   * @emits Base#userset
   * @emits Base#useraction
   */
  userset(key, value) {
    if (false === this.emit('userset', key, value)) return false;
    value = this.set(key, value);
    this.emit('useraction', key, value);
    return true;
  }

  getEventTarget() {
    return this.__event_target;
  }

  /**
   * Delegates all occuring DOM events of a specific DOM node to the widget.
   * This way the widget fires e.g. a click event if someone clicks on the
   * given DOM node.
   *
   * @method Base#delegateEvents
   *
   * @param {HTMLElement} element - The element all native events of the widget should be bound to.
   *
   * @returns {HTMLElement} The element
   *
   * @emits Base#delegated
   */
  delegateEvents(element) {
    const old_target = this.__event_target;

    if (old_target !== this.getEventTarget()) {
      throw new Error(
        'Cannot both overload getEventTarget() and call delegateEvents()'
      );
    }
    /**
     * Is fired when an element is delegated.
     *
     * @event Base#delegated
     *
     * @param {HTMLElement|Array} element - The element which receives all
     *      native DOM events.
     * @param {HTMLElement|Array} old_element - The element which previously
     *      received all native DOM events.
     */
    this.emit('delegated', element, old_target);

    if (old_target) removeNativeEvents.call(this, old_target);
    if (element) addNativeEvents.call(this, element);

    this.__event_target = element;

    return element;
  }

  /**
   * Register an event handler.
   *
   * @method Base#addEventListener
   *
   * @param {string} event - The event descriptor.
   * @param {Function} func - The function to call when the event happens.
   */
  on(event, func) {
    if (typeof event !== 'string') throw new TypeError('Expected string.');

    if (typeof func !== 'function') throw new TypeError('Expected function.');

    if (arguments.length !== 2) throw new Error('Bad number of arguments.');

    const __events = this.__events;

    if (isNativeEvent(event) && this.is_initialized) {
      const __event_target = this.getEventTarget();
      if (__event_target && !this.hasEventListeners(event))
        addActiveEventListener(__event_target, event, this.__native_handler);
    }
    addEvent(__events, event, func);
  }

  addEventListener(event, func) {
    return this.on(event, func);
  }

  hasEventListener(event, func) {
    const ev = this.__events;

    const handlers = ev[event];

    if (!handlers) return false;

    if (typeof func === 'undefined') return true;

    if (Array.isArray(handlers)) {
      return handlers.includes(func);
    }

    return handlers === func;
  }

  subscribe(event, func) {
    if (this.hasEventListener(event, func)) {
      throw new Error('Event handler already registered.');
    }

    let active = true;
    this.addEventListener(event, func);

    return () => {
      if (!active) return;
      active = false;
      if (!this.isDestructed()) {
        this.removeEventListener(event, func);
      }
      event = null;
      func = null;
    };
  }

  once(event, func) {
    const sub = this.subscribe(event, (...args) => {
      sub();
      return func(...args);
    });

    return sub;
  }

  /**
   * Removes the given function from the event queue.
   * If it is a native DOM event, it removes the DOM event listener
   * as well.
   *
   * @method Base#off
   *
   * @param {string} event - The event descriptor.
   * @param {Function} fun - The function to remove.
   */
  off(event, fun) {
    removeEvent(this.__events, event, fun);

    // remove native DOM event listener from getEventTarget()
    if (
      isNativeEvent(event) &&
      this.is_initialized &&
      !this.hasEventListeners(event)
    ) {
      const ev = this.getEventTarget();
      if (ev) removeActiveEventListener(ev, event, this.__native_handler);
    }
  }

  removeEventListener(event, func) {
    return this.off(event, func);
  }

  /**
   * Fires an event.
   *
   * @method Base#dispatchEvent
   *
   * @param {string} event - The event descriptor.
   * @param {...*} args - Event arguments.
   */
  emit(event) {
    let ev;
    let args;
    let v;

    ev = this.__events;

    if (ev !== void 0 && event in ev) {
      ev = ev[event];

      args = Array.prototype.slice.call(arguments, 1);

      v = dispatchEvents(this, ev, args);
      if (v !== void 0) return v;
    }

    ev = this.getStaticEvents();

    if (ev !== void 0 && event in ev) {
      ev = ev[event];

      if (args === void 0) args = Array.prototype.slice.call(arguments, 1);

      v = dispatchEvents(this, ev, args);
      if (v !== void 0) return v;
    }
  }

  dispatchEvent(event, ...args) {
    return this.emit(event, ...args);
  }

  /**
   * Test if the event descriptor has some handler functions in the queue.
   *
   * @method Base#hasEventListeners
   *
   * @param {string} event - The event desriptor.
   *
   * @returns {boolean} True if the event has some handler functions in the queue, false if not.
   */
  hasEventListeners(event) {
    let ev = this.__events;

    if (Object.prototype.hasOwnProperty.call(ev, event)) return true;

    ev = this.getStaticEvents();

    return ev && Object.prototype.hasOwnProperty.call(ev, event);
  }
}