component_helpers.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, error } from './utils/log.js';
import { sprintf, FORMAT } from './utils/sprintf.js';
import { HTML } from './utils/dom.js';
import { isNativeEvent } from './implements/base.js';
import { subscribeOptionsAttributes } from './options.js';
import { SubscriberMap } from './utils/subscriber_map.js';
import {
  initSubscriptions,
  unsubscribeSubscriptions,
} from './utils/subscriptions.js';

function attributeForWidget(Widget) {
  const attributes = [];
  const skip = ['class', 'id', 'container', 'element', 'styles'];
  const rename = ['title', 'role', 'tabindex'];

  for (let i in Widget.getOptionTypes()) {
    if (skip.indexOf(i) !== -1) continue;

    if (rename.indexOf(i) !== -1) i = 'aux' + i;

    attributes.push(i);
  }

  // this is for supporting AUX-OPTIONS
  attributes.push('options');

  return attributes;
}

const FORMAT_TYPES = new Set([
  'js',
  'javascript',
  'json',
  'html',
  'string',
  'number',
  'integer',
  'int',
  'sprintf',
  'regexp',
  'bool',
  'boolean',
  'array',
]);

function lowParseAttribute(type, x) {
  switch (type) {
    case 'js':
    case 'javascript':
      return new Function([], 'return (' + x + ');').call(this);
    case 'json':
      return JSON.parse(x);
    case 'html':
      return HTML(x);
    case 'string':
      return x;
    case 'number': {
      const f = parseFloat(x);
      if (f === f) return f;

      throw new Error(sprintf('Invalid number: "%s"', x));
    }
    case 'integer':
    case 'int': {
      const i = parseInt(x);
      if (i === i) return i;

      throw new Error(sprintf('Invalid int: "%s"', x));
    }
    case 'sprintf':
      return FORMAT(x);
    case 'regexp':
      return new RegExp(x);
    case 'bool':
    case 'boolean':
      x = x.trim();
      if (x === 'true' || x === '') {
        return true;
      } else if (x === 'false') {
        return false;
      }
      throw new Error(sprintf('Malformed boolean "%s"', x));
    case 'array':
      try {
        return lowParseAttribute.call(this, 'json', x);
      } catch (err) {
        /* empty */
      }
      return lowParseAttribute.call(this, 'js', x);
    default:
      throw new Error(sprintf('Unsupported attribute type "%s"', type));
  }
}

function parseAttribute(option_type, value) {
  const pos = value.indexOf(':');

  if (pos !== -1) {
    const format = value.substring(0, pos);

    if (FORMAT_TYPES.has(format)) {
      return lowParseAttribute.call(this, format, value.substring(pos + 1));
    }
  }

  let types = option_type.includes('|')
    ? option_type.split('|')
    : [option_type];

  types = types.filter((type) => FORMAT_TYPES.has(type));

  if (!types.length) {
    throw new Error('Unable to parse attribute without explicit format.');
  }

  let last_error;

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

    try {
      return lowParseAttribute.call(this, type, value);
    } catch (e) {
      last_error = e;
    }
  }

  throw last_error;
}

export function findParentNode(node) {
  while (node) {
    if (node.isAuxWidget) return node;

    // if the parent looks like a WebComponent which has not been upgraded, we
    // speculatively return it.
    const tagName = node.tagName;

    if (typeof tagName !== 'string') return null;

    // If it is a component but it has not been initialized, yet, we need to
    // assume it will become an aux widget in the future.
    if (tagName.includes('-') && !customElements.get(tagName.toLowerCase()))
      return node;

    node = node.parentNode;
  }

  return null;
}

const baseCache = new WeakMap();

function createComponent(base) {
  if (!base) base = HTMLElement;
  if (baseCache.has(base)) return baseCache.get(base);

  class BaseComponent extends base {
    _auxCalculateAttributes(parentAttributes) {
      const attribute_names = this.constructor.observedAttributes;
      const result = new Map();

      for (let i = 0; i < attribute_names.length; i++) {
        const name = attribute_names[i];
        if (!this.hasAttribute(name)) continue;

        if (name === 'options') {
          continue;
        } else {
          result.set(name, this.getAttribute(name));
        }
      }

      if (parentAttributes) {
        parentAttributes.forEach((value, key) => {
          if (result.has(key)) return;
          if (!attribute_names.includes(key)) return;
          result.set(key, value);
        });
      }

      return result;
    }

    _auxAttributeChanged(name, oldValue, newValue) {
      this._auxAttributes.set(name, newValue);

      if (name.startsWith('aux')) name = name.substring(3);

      try {
        const widget = this.auxWidget;
        const type = widget.getOptionType(name);

        if (typeof type !== 'string') {
          throw new TypeError('Option does not exist.');
        }

        if (newValue !== null) {
          const value = parseAttribute.call(this, type, newValue);
          widget.set(name, value);
        } else {
          widget.reset(name);
        }
      } catch (e) {
        warn(
          '%o: Setting attribute %o on generated an error: %o',
          this,
          name,
          e
        );
      }
    }

    _auxUpdateAttributes(parentAttributes) {
      const new_attributes = this._auxCalculateAttributes(parentAttributes);
      const current_attributes = this.auxAttributes();

      // delete and update
      current_attributes.forEach((oldValue, name) => {
        if (!new_attributes.has(name)) {
          this._auxAttributeChanged(name, oldValue, null);
        } else {
          const newValue = new_attributes.get(name);

          if (newValue !== oldValue) {
            this._auxAttributeChanged(name, oldValue, newValue);
          }
        }
      });

      // new attributes
      new_attributes.forEach((newValue, name) => {
        if (!current_attributes.has(name)) {
          this._auxAttributeChanged(name, null, newValue);
        }
      });
    }

    auxAttributes() {
      return this._auxAttributes;
    }

    auxOptions(WidgetType) {
      const ret = {};
      const _options = WidgetType.getOptionTypes();
      const attribute_names = this.constructor.observedAttributes;
      const attributes = this.auxAttributes();

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

        if (!attributes.has(name)) continue;

        const option_name = name.startsWith('aux') ? name.substring(3) : name;

        const type = _options[option_name];
        const attribute_value = attributes.get(name);

        ret[option_name] = parseAttribute.call(this, type, attribute_value);
      }

      return ret;
    }

    constructor(widget) {
      super();
      this._auxEventHandlers = null;
      this._auxAttributesSubscription = null;
      this._auxAttributes = this._auxCalculateAttributes(null);
      this._auxParentNode = void 0;
      this._detachFromParent = initSubscriptions();
      this._positionIndependent = true;
    }

    _auxParentChanged() {
      const parentNode = findParentNode(this.parentNode);

      // The parent node has not changed, no need to do anything.
      if (parentNode === this._auxParentNode && this._positionIndependent) {
        return;
      }

      this._detachFromParent = unsubscribeSubscriptions(this._detachFromParent);

      if (!this.isConnected && parentNode === null) {
        this._auxParentNode = void 0;
        return null;
      }

      this._auxParentNode = parentNode;

      let unsubscribe;

      if (parentNode && !parentNode.isAuxWidget) {
        // We have a parent node but it has not been initialized, yet.
        unsubscribe = subscribeWhenDefined(parentNode.tagName, () => {
          // retry
          this._auxParentNode = void 0;
          this._auxParentChanged();
        });
      } else {
        const parentWidget = parentNode ? parentNode.auxWidget : null;
        unsubscribe = this._attachToParent(parentWidget);
      }

      this._detachFromParent = unsubscribe;
    }

    /*
     * Called with the given parentNode and parentWidget. This
     * method is only called when this node is part of a subtree.
     */
    _attachToParent(parentWidget) {
      return null;
    }

    connectedCallback() {
      if (!this.isConnected) return;

      const options = this.getAttribute('options');

      if (options) {
        // this will initially happen in the attributeChangedCallback after the
        // constructor and before the connectedCallback
        if (!this._auxAttributesSubscription)
          this._auxAttributesSubscription = subscribeOptionsAttributes(
            this.parentNode,
            options,
            (attr) => {
              this._auxUpdateAttributes(attr);
            }
          );
      }

      this._auxParentChanged();
    }

    disconnectedCallback() {
      this._auxAttributesSubscription = unsubscribeSubscriptions(
        this._auxAttributesSubscription
      );
      this._auxParentChanged();
    }

    attributeChangedCallback(name, oldValue, newValue) {
      const attr = this.auxAttributes();

      // all attribute changed callbacks will fire once befor the initial
      // connectedCallback(). This check prevents those because we collect all
      // attributes initially in the constructor.
      if (oldValue === null && newValue === attr.get(name)) return;

      if (name === 'options') {
        if (!this.isConnected) return;
        if (oldValue === newValue) return;

        const subscriptions = this._auxAttributesSubscription;

        if (subscriptions) {
          this._auxAttributesSubscription = null;
          subscriptions();
        }

        const options = newValue;

        if (options) {
          this._auxAttributesSubscription = subscribeOptionsAttributes(
            this.parentNode,
            options,
            (attr) => {
              this._auxUpdateAttributes(attr);
            }
          );
        } else {
          this._auxUpdateAttributes(null);
        }
      } else {
        this._auxAttributeChanged(name, oldValue, newValue);
      }
    }

    addEventListener(type, ...args) {
      if (!isNativeEvent(type) && this.auxWidget) {
        let handlers = this._auxEventHandlers;

        if (handlers === null) {
          this._auxEventHandlers = handlers = new Map();
        }

        if (!handlers.has(type)) {
          const cb = (...args) => {
            this.dispatchEvent(
              new CustomEvent(type, { detail: { args: args } })
            );
          };

          handlers.set(type, cb);
          this.auxWidget.on(type, cb);
        }
      }

      super.addEventListener(type, ...args);
    }

    get isAuxWidget() {
      return true;
    }

    auxResize() {
      this.auxWidget.triggerResize();
    }
  }

  baseCache.set(base, BaseComponent);

  return BaseComponent;
}

const whenDefinedSubscribers = new SubscriberMap();

function subscribeWhenDefined(tagName, callback) {
  tagName = tagName.toLowerCase();

  if (!whenDefinedSubscribers.has(tagName)) {
    customElements.whenDefined(tagName).then(() => {
      whenDefinedSubscribers.call(tagName);
      whenDefinedSubscribers.removeAll(tagName);
    });
  }

  return whenDefinedSubscribers.subscribe(tagName, callback);
}

let a_div;

function defineOptionsAsProperties(component, options) {
  if (!a_div) a_div = document.createElement('div');

  options.forEach((name) => {
    const property_name = name in a_div ? 'aux' + name : name;
    Object.defineProperty(component.prototype, property_name, {
      get: function () {
        return this.auxWidget.get(name);
      },
      set: function (value) {
        const widget = this.auxWidget;
        if (value === void 0) {
          widget.reset(name);
        } else {
          widget.set(name, value);
        }
      },
    });
  });
}

export function componentFromWidget(Widget, base) {
  const compbase = createComponent(base);
  const attributes = attributeForWidget(Widget);

  const component = class extends compbase {
    static get observedAttributes() {
      return attributes;
    }

    constructor() {
      super();

      const options = this.auxOptions(Widget, attributes);

      options.element = this;

      this.auxWidget = new Widget(options);
    }

    _attachToParent(parentWidget) {
      const widget = this.auxWidget;

      if (parentWidget === null) {
        widget.setParent(null);
        return () => {
          widget.setParent(void 0);
          widget.disableDraw();
        };
      } else {
        parentWidget.addChild(widget);

        return () => {
          // There are situations where we are removed from the
          // parent programmatically.
          if (widget.parent === parentWidget) {
            parentWidget.removeChild(widget);
          }
          widget.disableDraw();
        };
      }
    }
  };

  defineOptionsAsProperties(component, attributes);

  return component;
}

export function subcomponentFromWidget(
  Widget,
  ParentWidget,
  appendCallback,
  removeCallback,
  base,
  positionSensitive
) {
  const compbase = createComponent(base);
  const attributes = attributeForWidget(Widget);

  if (!appendCallback)
    appendCallback = (parent, child) => {
      parent.addChild(child);
    };

  if (!removeCallback)
    removeCallback = (parent, child) => {
      parent.removeChild(child);
    };

  const component = class extends compbase {
    static get observedAttributes() {
      return attributes;
    }

    constructor() {
      super();
      this._positionIndependent = !positionSensitive;
      const options = this.auxOptions(Widget);

      options.class = this.getAttribute('class');

      this.auxWidget = new Widget(options);
    }

    connectedCallback() {
      super.connectedCallback();
      this.style.display = 'none';
    }

    _attachToParent(parentWidget) {
      if (parentWidget instanceof ParentWidget) {
        const widget = this.auxWidget;
        appendCallback(parentWidget, widget, this);
        return () => {
          removeCallback(parentWidget, widget, this);
          widget.disableDraw();
        };
      } else {
        error('Missing parent widget: ', this);
      }
    }
  };

  defineOptionsAsProperties(component, attributes);

  return component;
}

export function defineComponent(name, component, options) {
  customElements.define('aux-' + name, component, options);
}

/**
 * Interface implemented by all WebComponents based on AUX Widgets.
 *
 * Each Component maps both attributes and properties onto options of the same
 * name. The mapping of attributes is only one-directional, i.e. attributes are
 * turned into options but not the other way around.
 *
 * Properties are only defined on the corresponding component under the same name,
 * if the base Element class does not already define them, e.g. the property
 * `title` is already defined on the HTMLElement. Properties such as 'title'
 * for which the property would override an existing property are defined as
 * aliases with the prefix 'aux', e.g. 'auxtitle' is defined and maps onto the
 * 'title' option in the widget.
 *
 * @interface Component
 * @property auxWidget {Widget} - The AUX widget object of this component.
 * @property isAuxWidget {boolean} - Returns true. This can be used to detect AUX components.
 */
/**
 * Trigger a resize. This leads the widget to recalculates its size. Some
 * components, such as those which have scales, need this to redraw themselves
 * correctly.
 *
 * @method Component#auxResize
 */