widgets/navigation.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 { defineChildWidget } from '../child_widget.js';
import {
  addClass,
  removeClass,
  toggleClass,
  innerHeight,
  innerWidth,
  outerWidth,
  outerHeight,
} from '../utils/dom.js';
import { Button } from './button.js';
import { Buttons } from './buttons.js';
import { Container } from './container.js';
import { defineRender } from '../renderer.js';
import { domScheduler } from '../dom_scheduler.js';
import { MASK_RENDER } from '../scheduler/scheduler.js';

function easeLinear(t) {
  return t;
}

function easeInOut(t) {
  if (t < 0.5) {
    return 2 * t * t;
  } else {
    return 1 - Math.pow(-2 * t + 2, 2) / 2;
  }
}

class ScrollAnimation {
  constructor(options) {
    this.element = options.element;
    this.duration = options.duration;

    if (this.duration < 0) {
      this.duration = 0;
    }

    this.from = options.from;
    this.to = options.to;
    this.vertical = options.vertical;
    this.easing = options.easing || easeLinear;
    this.startTime = performance.now();
    this.paused = true;
    this.scheduled = false;

    this._draw = () => {
      this.scheduled = false;
      if (this.paused) return;
      let t =
        this.duration > 0
          ? (performance.now() - this.startTime) / this.duration
          : 1;

      // catch NaN
      if (t < 0) t = 0;
      else if (t > 1) t = 1;

      const pos = this.from + this.easing(t) * (this.to - this.from);

      if (this.vertical) {
        this.element.scrollTop = pos;
      } else {
        this.element.scrollLeft = pos;
      }

      if (t < 1) {
        domScheduler.scheduleNext(MASK_RENDER, this._draw);
      }
    };
    this.start();
  }

  stop() {
    this.paused = true;
  }

  pause() {
    this.stop();
  }

  start() {
    if (this.scheduled) return;
    this.paused = false;
    domScheduler.scheduleNext(MASK_RENDER, this._draw);
  }
}

/**
 * The <code>useraction</code> event is emitted when a widget gets modified by user interaction.
 * The event is emitted for the option <code>select</code>.
 *
 * @event Navigation#useraction
 * @param {string} name - The name of the option which was changed due to the users action
 * @param {mixed} value - The new value of the option
 */

function autoArrows() {
  const O = this.options;
  if (!O.auto_arrows) return;
  const B = this.buttons.getButtons();
  const vert = O.direction === 'vertical';
  const cons = vert
    ? innerHeight(this.buttons.element, undefined, true)
    : innerWidth(this.buttons.element, undefined, true);
  let list;
  if (B.length) {
    const lastb = B[B.length - 1].element;
    if (vert) {
      list = lastb.offsetTop + outerHeight(lastb, true, undefined, true);
    } else {
      list = lastb.offsetLeft + outerWidth(lastb, true, undefined, true);
    }
  } else {
    list = 0;
  }
  this.update('arrows', list > cons);
}

function prevClicked() {
  this.parent.userset('select', Math.max(0, this.parent.get('select') - 1));
}
function prevDblClicked() {
  this.parent.userset('select', 0);
}

function nextClicked() {
  this.parent.userset(
    'select',
    Math.min(
      this.parent.buttons.getButtons().length - 1,
      this.parent.get('select') + 1
    )
  );
}
function nextDblClicked() {
  this.parent.userset('select', this.parent.buttons.getButtons().length - 1);
}

function measure_clip() {
  const buttons = this.buttons;

  this.update('_clip_height', innerHeight(buttons.element, undefined, true));
  this.update('_clip_width', innerWidth(buttons.element, undefined, true));
}

/**
 * Navigation is a {@link Container} including a {@Buttons} widget for e.g. navigating between
 * pages inside a {@link Pager}. It keeps the currently highlighted {@link Button}
 * inside the visible area and adds previous and next {@link Button}s
 * if needed.
 *
 * @extends Container
 *
 * @param {Object} [options={ }] - An object containing initial options.
 *
 * @property {String} [options.direction="horizontal"] - The layout of
 *   the Navigation, either `horizontal` or `vertical`.
 * @property {Boolean} [options.icons=true] - Set icons on previous and next {@link Button}s to standard arrows.
 *   Set to `false` to use custom icons via child widget.
 * @property {Boolean} [options.arrows=false] - Show or hide previous and next {@link Button}s.
 * @property {Boolean} [options.auto_arrows=true] - Set to false to disable
 *   automatic creation of the previous/next buttons.
 * @property {Integer} [options.scroll=500] - Duration of the scrolling animation.
 *
 * @class Navigation
 */
export class Navigation extends Container {
  static get _options() {
    return {
      _clip_width: 'number',
      _clip_height: 'number',
      _list_width: 'number',
      _list_height: 'number',
      _button_positions: 'object',

      direction: 'string',
      icons: 'boolean',
      arrows: 'boolean',
      auto_arrows: 'boolean',
      resized: 'boolean',
      scroll: 'int',
    };
  }

  static get options() {
    return {
      direction: 'horizontal',
      icons: true,
      arrows: false,
      auto_arrows: true,
      resized: false,
      scroll: 500,
    };
  }

  static get static_events() {
    return {
      set_direction: function (value) {
        if (!this.get('icons')) return;
        this.prev.set('icon', value === 'vertical' ? 'arrowup' : 'arrowleft'); //"\u25B2" : "\u25C0");
        this.next.set(
          'icon',
          value === 'vertical' ? 'arrowdown' : 'arrowright'
        ); //"\u25BC" : "\u25B6");
      },
      hide: function () {
        if (this._scroll_animation) {
          this._scroll_animation.pause();
        }
      },
      show: function () {
        if (this._scroll_animation) {
          this._scroll_animation.start();
        }
      },
      set_select: function (val) {
        this.prev.set('disabled', val <= 0);
        this.next.set('disabled', val === this.buttons.getButtons().length - 1);
      },
      set_arrows: function (arrows) {
        this.set('show_prev', arrows);
        this.set('show_next', arrows);
      },
    };
  }

  static get renderers() {
    return [
      defineRender('direction', function (direction) {
        const { element } = this;
        removeClass(element, 'aux-vertical', 'aux-horizontal');
        addClass(element, 'aux-' + direction);
      }),
      defineRender('arrows', function (arrows) {
        toggleClass(this.element, 'aux-over', arrows);
      }),
      defineRender(
        [
          'direction',
          'scroll',
          'select',
          '_clip_width',
          '_clip_height',
          '_list_width',
          '_list_height',
          '_button_positions',
        ],
        function (direction, scroll) {
          if (this._scroll_animation) {
            this._scroll_animation.stop();
            this._scroll_animation = null;
          }

          const position = this._getButtonScrollPosition();
          const is_vertical = direction === 'vertical';
          const from = is_vertical ? this._scroll_top : this._scroll_left;

          if (position !== from) {
            this._scroll_animation = new ScrollAnimation({
              element: this.buttons.element,
              duration: scroll,
              from: from,
              to: position,
              easing: easeInOut,
              vertical: is_vertical,
            });
          }
        }
      ),
    ];
  }

  _getButtonScrollPosition() {
    const {
      select,
      _clip_width,
      _clip_height,
      _list_width,
      _list_height,
      _button_positions,
      direction,
    } = this.options;
    const button_list = this.buttons.getButtons();

    if (select < 0 || select >= button_list.length) return 0;

    const button_position = _button_positions.get(button_list[select]);

    if (!button_position) return 0;

    const is_vertical = direction === 'vertical';
    const clip_size = is_vertical ? _clip_height : _clip_width;
    const list_size = is_vertical ? _list_height : _list_width;
    const offset = is_vertical ? button_position.top : button_position.left;
    const button_size = is_vertical
      ? button_position.height
      : button_position.width;

    /*
        console.log(O, 'clip_size', clip_size, 'list_size', list_size, 'offset', offset,
                    'button_size', button_size);

        console.log('innerHeight', innerHeight(this.buttons.element));
        */

    let pos = Math.min(
      offset + button_size / 2 - clip_size / 2,
      list_size - clip_size
    );

    if (pos < 0) pos = 0;

    return pos;
  }

  initialize(options) {
    super.initialize(options);
    /**
     * @member {HTMLDivElement} Navigation#element - The main DIV container.
     *   Has class <code>.aux-navigation</code>.
     */

    // these properties contain the scroll position of the buttons child
    // element
    this._scroll_left = 0;
    this._scroll_top = 0;

    this.set('_button_positions', new Map());
  }

  initialized() {
    super.initialized();

    this.addSubscriptions(
      this.buttons.buttons.forEachAsync((button) => {
        let measured = false;
        const positions = this.get('_button_positions');
        const updateLength = () => {
          let list_width = 0;
          let list_height = 0;

          positions.forEach((info) => {
            list_width = Math.max(list_width, info.width + info.left);
            list_height = Math.max(list_height, info.height + info.top);
          });

          this.update('_list_width', list_width);
          this.update('_list_height', list_height);
        };

        const info = {
          width: 0,
          height: 0,
          left: 0,
          top: 0,
        };
        const measure = (_button) => {
          const element = _button.element;

          info.width = outerWidth(element, false, undefined, true);
          info.height = outerHeight(element, false, undefined, true);
          info.left = element.offsetLeft;
          info.top = element.offsetTop;

          if (!measured) {
            measured = true;
            positions.set(_button, info);
          }
          updateLength();
          this.invalidate('_button_positions');
        };

        let sub = button.observeResize(measure);

        return () => {
          if (!sub) return;
          sub();
          sub = null;
          if (measured) {
            positions.delete(button);
            updateLength();
            this.invalidate('_button_positions');
          }
        };
      }),
      this.buttons.observeResize(measure_clip.bind(this)),
      this.buttons.subscribe('scroll', () => {
        this._scroll_top = this.buttons.element.scrollTop;
        this._scroll_left = this.buttons.element.scrollLeft;
      })
    );

    this.set('auto_arrows', this.options.auto_arrows);
    this.set('direction', this.options.direction);
  }

  getResizeTargets() {
    return [this.buttons.element];
  }

  resize() {
    autoArrows.call(this);
    this._scroll_top = this.buttons.element.scrollTop;
    this._scroll_left = this.buttons.element.scrollLeft;
    super.resize();
  }

  draw(O, element) {
    addClass(element, 'aux-navigation');
    super.draw(O, element);
  }

  addButton(...arg) {
    return this.buttons.addButton(...arg);
  }

  addButtons(...arg) {
    return this.buttons.addButtons(...arg);
  }

  removeButton(...arg) {
    if (this.buttons) return this.buttons.removeButton(...arg);
  }

  empty(...arg) {
    return this.buttons.empty(...arg);
  }

  destroy() {
    super.destroy();

    if (this._scroll_animation) {
      this._scroll_animation.stop();
      this._scroll_animation = null;
    }
  }
}
/**
 * @member {Buttons} Navigation#buttons - The {@link Buttons} of the Navigation.
 */
defineChildWidget(Navigation, 'buttons', {
  create: Buttons,
  show: true,
  inherit_options: true,
  userset_delegate: true,
  static_events: {
    set__focus: function (focus) {
      const button = this.buttons.list[focus];
      if (!button) return;
      //button.element.scrollIntoView({behavior: "smooth"});
    },
  },
});

/**
 * @member {Button} Navigation#prev - The previous arrow {@link Button} instance.
 */
defineChildWidget(Navigation, 'prev', {
  create: Button,
  show: true,
  default_options: {
    class: 'aux-previous',
    dblclick: 300,
    tabindex: false,
  },
  static_events: {
    click: prevClicked,
    dblclick: prevDblClicked,
  },
  append: function () {
    this.element.appendChild(this.prev.element);
    if (!this.prev.__measure_clip) {
      this.prev.observeResize(measure_clip.bind(this));
      this.prev.__measure_clip = true;
    }
  },
});

/**
 * @member {Button} Navigation#next - The next arrow {@link Button} instance.
 */
defineChildWidget(Navigation, 'next', {
  create: Button,
  show: true,
  default_options: {
    class: 'aux-next',
    dblclick: 300,
    tabindex: false,
  },
  static_events: {
    click: nextClicked,
    dblclick: nextDblClicked,
  },
  append: function () {
    this.element.appendChild(this.next.element);
    if (!this.next.__measure_clip) {
      this.next.observeResize(measure_clip.bind(this));
      this.next.__measure_clip = true;
    }
  },
});