skins_Skin.js

/**
 *  File    : skins/Skin.js
 *  Created : 29/04/2015
 *  By      : Francesc Busquets <francesc@gmail.com>
 *
 *  JClic.js
 *  An HTML5 player of JClic activities
 *  https://projectestac.github.io/jclic.js
 *
 *  @source https://github.com/projectestac/jclic.js
 *
 *  @license EUPL-1.2
 *  @licstart
 *  (c) 2000-2020 Educational Telematic Network of Catalonia (XTEC)
 *
 *  Licensed under the EUPL, Version 1.1 or -as soon they will be approved by
 *  the European Commission- subsequent versions of the EUPL (the "Licence");
 *  You may not use this work except in compliance with the Licence.
 *
 *  You may obtain a copy of the Licence at:
 *  https://joinup.ec.europa.eu/software/page/eupl
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the Licence is distributed on an "AS IS" basis, WITHOUT
 *  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 *  Licence for the specific language governing permissions and limitations
 *  under the Licence.
 *  @licend
 *  @module
 */

/* global Promise, window, document, navigator, ClipboardItem, Blob */

import $ from 'jquery';
import { appendStyleAtHead, cloneObject, getMsg, setLogLevel, log, getRootHead, toCssSize, $HTML, getPercent, getHMStime, settings } from '../Utils.js';
import { Container, Dimension, Rectangle } from '../AWT.js';

// Use Webpack to import CSS and SVG files
import basicCSS from './assets/basic.css';
import waitAnimCSS from './assets/waitAnim.css';
import reportsCSS from './assets/reports.css';
import waitImgSmall from './assets/waitImgSmall.svg';
import waitImgBig from './assets/waitImgBig.svg';
import appLogo from './assets/appLogo.svg';
import closeDialogIcon from './assets/closeDialogIcon.svg';
import okDialogIcon from './assets/okDialogIcon.svg';
import copyIcon from './assets/copyIcon.svg';

/**
 * This abstract class manages the layout, position ans size of the visual components of JClic:
 * player window, message box, counters, buttons, status... and also the appearance of the main
 * container.
 * The basic implementation of Skin is {@link module:skins/DefaultSkin.DefaultSkin DefaultSkin}.
 * @abstract
 * @extends module:AWT.Container
 */
export class Skin extends Container {
  /**
   * Skin constructor
   * @param {module:JClicPlayer.JClicPlayer} ps - The `PlayStation` (currently a {@link module:JClicPlayer.JClicPlayer JClicPlayer}) used to load and
   * realize the media objects needed tot build the Skin.
   * @param {string} [name] - The skin name
   * @param {object} [options] - Optional parameter with additional options
   */
  constructor(ps, name = null, options = {}) {

    // Skin extends [AWT.Container](AWT.html)
    super();

    // Save parameters for later use
    this.ps = ps;
    if (name !== null)
      this.name = name;
    this.options = options;

    if (this.options.skinId)
      this.skinId = this.options.skinId;

    if (!Skin.registerStyleSheet(this.skinId, ps)) {
      let css = this._getStyleSheets('default');
      let twoThirds = this._getStyleSheets('twoThirds');
      if (twoThirds.length > 0)
        css += ` @media (max-width:${this.twoThirdsMedia.width}px),(max-height:${this.twoThirdsMedia.height}px){${twoThirds}}`;
      let half = this._getStyleSheets('half');
      if (half.length > 0)
        css += ` @media (max-width:${this.halfMedia.width}px),(max-height:${this.halfMedia.height}px){${half}}`;
      appendStyleAtHead(css.replace(/\.ID/g, `.${this.skinId}`), ps);
    }

    let msg = '';

    this.$div = $('<div/>', { class: this.skinId });
    this.$playerCnt = $('<div/>', { class: 'JClicPlayerCnt' });

    // Add waiting panel and progress bar
    this.$progress = $('<progress/>', { class: 'progressBar' })
      .css({ display: 'none' });
    this.$waitPanel = $('<div/>')
      .css({ display: 'none', 'background-color': 'rgba(255, 255, 255, .60)', 'z-index': 99 })
      .append($('<div/>', { class: 'waitPanel' }).css({ display: 'flex', 'flex-direction': 'column' })
        .append($('<div/>', { class: 'animImgBox' })
          .append($(this.waitImgBig), $(this.waitImgSmall)))
        .append(this.$progress));
    this.$playerCnt.append(this.$waitPanel);

    this.buttons = cloneObject(Skin.prototype.buttons);
    this.counters = cloneObject(Skin.prototype.counters);
    this.msgArea = cloneObject(Skin.prototype.msgArea);

    // Create dialog overlay and panel
    this.$dlgOverlay = $('<div/>', { class: 'dlgOverlay' }).css({
      'z-index': 98,
      position: 'fixed',
      left: 0,
      top: 0,
      width: '100%',
      height: '100%',
      display: 'none',
      'background-color': 'rgba(30,30,30,0.7)'
    }).on('click', () => {
      if (!this._isModalDlg)
        // Non-modal dialogs are closed on click outside the main area
        this._closeDlg(true);
      return false;
    });

    const $dlgDiv = $('<div/>', {
      class: 'dlgDiv',
      role: 'dialog',
      'aria-labelledby': ps.getUniqueId('ReportsLb'),
      'aria-describedby': ps.getUniqueId('ReportsCnt')
    }).css({
      display: 'inline-block',
      position: 'relative',
      top: '50%',
      left: '50%',
      transform: 'translate(-50%, -50%)'
    }).on('click', () => {
      // Clicks not passed to parent
      return false;
    });

    this.$dlgMainPanel = $('<div/>', { class: 'dlgMainPanel', id: ps.getUniqueId('ReportsCnt') });
    this.$dlgBottomPanel = $('<div/>', { class: 'dlgBottomPanel', role: 'navigation' });

    // Basic dialog structure:
    this.$div.append(
      this.$playerCnt,
      this.$dlgOverlay.append(
        $dlgDiv.append(
          this.$dlgMainPanel,
          this.$dlgBottomPanel)));

    msg = getMsg('JClic logo');
    this.$infoHead = $('<div/>', { class: 'infoHead' })
      .append($('<div/>', { class: 'headTitle unselectableText' })
        .append($(this.appLogo, { 'aria-label': msg }).css({ width: '1.5em', height: '1.5em', 'vertical-align': 'bottom' })
          .on('dblclick', () => {
            // Double click on JClic logo is a hidden method to increase verbosity on Javascript console
            setLogLevel('all');
            log('trace', 'Log level set to "trace"');
          }))
        .append($('<span/>').html('JClic.js')))
      .append($('<p/>').css({ 'margin-top': 0, 'margin-left': '3.5em' })
        .append($('<a/>', { href: 'https://projectes.xtec.cat/clic/' }).html('https://projectes.xtec.cat/clic/'))
        .append($('<br>'))
        .append($('<span/>').html(`${getMsg('Version')} ${settings.VERSION}`)));

    this.$reportsPanel = $('<div/>', { class: 'reportsPanel', role: 'document' });

    msg = getMsg('Copy data to clipboard');
    this.$copyBtn = $('<button/>', { title: msg, 'aria-label': msg })
      .append($(this.copyIcon).css({ width: '26px', height: '26px' }))
      .on('click', () => {
        const item = new ClipboardItem({
          'text/plain': new Blob([`===> ${getMsg('The data has been copied in HTML format. Please paste them into a spreadsheet or in a rich text editor')} <===`], {type: 'text/plain'}),
          'text/html': new Blob([this.$reportsPanel.html()], {type: 'text/html'}),
        });
        navigator.clipboard.write([item])
          .then(() => this.$copyBtn.parent().append(
            $('<div/>', { class: 'smallPopup' })
              .html(getMsg('The data has been copied to clipboard'))
              .fadeIn()
              .delay(3000)
              .fadeOut(function () { $(this).remove(); })))
          .catch(err => this.$copyBtn.parent().append(
            $('<div/>', { class: 'smallPopup' })
              .html(`ERROR: Unable to write data into the clipboard: ${err}`)
              .fadeIn()
              .delay(3000)
              .fadeOut(function () { $(this).remove(); })));
      });

    msg = getMsg('Close');
    this.$closeDlgBtn = $('<button/>', { title: msg, 'aria-label': msg })
      .append($(this.closeDialogIcon).css({ width: '26px', height: '26px' }))
      .on('click', () => this._closeDlg(true));

    msg = getMsg('OK');
    this.$okDlgBtn = $('<button/>', { title: msg, 'aria-label': msg })
      .append($(this.okDialogIcon).css({ width: '26px', height: '26px' }))
      .on('click', () => this._closeDlg(true));

    msg = getMsg('Cancel');
    this.$cancelDlgBtn = $('<button/>', { title: msg, 'aria-label': msg })
      .append($(this.closeDialogIcon).css({ width: '26px', height: '26px' }))
      .on('click', () => this._closeDlg(false));

    // Registers this Skin in the list of realized Skin objects
    Skin.skinStack.push(this);
  }

  /**
   * Registers a new type of skin
   * @param {string} skinName - The name used to identify this skin
   * @param {function} skinClass - The skin class, usually extending Skin
   * @returns {module:skins/Skin.Skin} - The provided skin class
   */
  static registerClass(skinName, skinClass) {
    Skin.CLASSES[skinName] = skinClass;
    return skinClass;
  }

  /**
   * Checks if the provided stylesheet ID is already registered in the root node where the current player is placed
   * @param {string} skinId - The unique identifier of the skin to check
   * @param {module:JClicPlayer.JClicPlayer} [ps] - An optional `PlayStation` (currently a {@link module:JClicPlayer.JClicPlayer JClicPlayer}) used as a base to find the root node
   * @returns {boolean} - _true_ when the skin stylesheet is already defined in the current root node, _false_ otherwise
   */
  static registerStyleSheet(skinId, ps) {
    let result = false;
    const root = getRootHead(ps);
    if (!root['__JClicID'])
      root.__JClicID = `SK${Skin.lastId++}`;

    let styles = Skin.rootStyles[root.__JClicID];
    if (!styles) {
      styles = [];
      Skin.rootStyles[root.__JClicID] = styles;
    }

    if (styles.indexOf(skinId) < 0) {
      log('trace', `Stylesheet "${skinId}" has been registered for root node labeled as "${root.__JClicID}"`);
      styles.push(skinId);
    } else
      result = true;

    return result;
  }

  /**
   * Gets the specified Skin from `skinStack`, or creates a new one if not found.
   * This function should be used only through `Skin.getSkin`
   * @param {string} skinName - The name of the searched skin
   * @param {module:JClicPlayer.JClicPlayer} ps - The PlayStation (usually a {@link module:JClicPlayer.JClicPlayer JClicPlayer}) used to build the new skin.
   * @param {object} [options] - Optional parameter with additional options
   * @returns {module:skins/Skin.Skin}
   */
  static getSkin(skinName = 'default', ps, options = {}) {
    skinName = skinName || 'default';

    // Correct old skin names
    if (skinName.charAt(0, 1) === '@' && skinName.endsWith('.xml'))
      skinName = skinName.substring(1, skinName.length - 4);

    // look for the skin in the stack of realized skins
    if (skinName && ps) {
      // TODO: Check also `options`!
      const sk = Skin.skinStack.find(s => s.name === skinName && s.ps === ps);
      if (sk)
        return sk;
    }

    // Locates the class of the requested Skin (or [DefaultSkin](DefaultSkin.html)
    // if not specified). When not found, a new one is created and registered in `skinStack`
    let cl = Skin.CLASSES[skinName];
    if (!cl) {
      // Process custom skin XML files
      const mbe = ps.project.mediaBag.getElement(skinName, false);
      if (mbe && mbe.data) {
        options = Object.assign({}, options, mbe.data);
        options.skinId = `JClic-${skinName.replace('.xml', '')}`;
      }

      if (!ps.zip
        && options.class === 'edu.xtec.jclic.skins.BasicSkin'
        && options.image
        && ps.project.mediaBag.getElement(options.image, false)
        && ps.project.mediaBag.getElement(options.image, false).data)
        cl = Skin.CLASSES.custom;
      else {
        log('warn', `Unknown skin class: ${skinName}`);
        cl = Skin.CLASSES.default;
      }
    }

    // Build and return the requested skin
    return new cl(ps, skinName, options);
  }

  /**
   * Returns the CSS styles used by this skin. This method should be called only from
   * the `Skin` constructor, and overridded by subclasses if needed.
   * @param {string} media - A specific media size. Possible values are: 'default', 'half' and 'twoThirds'
   * @returns {string}
   */
  _getStyleSheets(media = 'default') {
    return media === 'default' ? (this.basicCSS + this.waitAnimCSS + this.reportsCSS) : '';
  }

  /**
   * Attaches a {@link module:JClicPlayer.JClicPlayer JClicPlayer} object to this Skin
   * @param {module:JClicPlayer.JClicPlayer} player
   */
  attach(player) {
    this.detach();
    if (player !== null && player.skin !== null)
      player.skin.detach();
    this.player = player;
    this.$playerCnt.prepend(player.$div);
    this.setSkinSizes();
    player.$mainContainer.append(this.$div);
  }

  /**
   * Sets the 'size' CSS values (max, min and compulsory) to the main `div` of this skin
   * @param {boolean} full - `true` when the skin is in full screen mode
   */
  setSkinSizes(full) {
    const
      css = {},
      topHeight = this.player?.$topDiv.height() || 0,
      nilValue = this.player.fullScreenChecked ? 'inherit' : null;

    // When `full` no set, detect the current status
    if (typeof full === 'undefined')
      full = document && document.fullscreenElement ? true : false;

    toCssSize(full ? '100vw' : this.ps.options.minWidth, css, 'min-width', nilValue);
    toCssSize(full ? '100vh' : this.ps.options.minHeight, css, 'min-height', nilValue);
    toCssSize(full ? '100vw' : this.ps.options.maxWidth, css, 'max-width', nilValue);
    toCssSize(full ? '100vh' : this.ps.options.maxHeight, css, 'max-height', nilValue);
    toCssSize(full ? '100vw' : this.ps.options.width, css, 'width', '100%');
    toCssSize(full ? '100vh' : this.ps.options.height, css, 'height', topHeight > 0 ? '100%' : '100vh');
    this.$div.css(css);
  }

  /**
   * Detaches the `player` element from this Skin
   */
  detach() {
    if (this.player !== null) {
      this.player.$div.remove();
      this.$div.detach();
      this.player = null;
    }
  }

  /**
   * Updates the graphic contents of this skin.
   * This method should be called from {@link module:skins/Skin.Skin#update}
   * @override
   * @param {module:AWT.Rectangle} dirtyRegion - Specifies the area to be updated. When `null`, it's the
   * whole panel.
   */
  updateContent(dirtyRegion) {
    if (this.$msgBoxDivCanvas) {
      const ctx = this.$msgBoxDivCanvas.get(-1).getContext('2d');
      ctx.clearRect(0, 0, ctx.canvas.clientWidth, ctx.canvas.clientHeight);
      this.msgBox.update(ctx, dirtyRegion);
    }
    return super.updateContent();
  }

  /**
   * Resets all counters
   * @param {boolean} bEnabled - Leave it enabled/disabled
   */
  resetAllCounters(bEnabled) {
    $.each(this.counters, (_name, counter) => {
      if (counter !== null) {
        counter.value = 0;
        counter.countDown = 0;
        counter.enabled = bEnabled;
        counter.refreshDisplay();
      }
    });
  }

  /**
   * Sets/unsets the 'wait' state
   * @param {boolean} status - Whether to set or unset the wait status. When `undefined`, the
   * `waitCursorCount` member is evaluated to decide if the wait state should be activated or deactivated.
   */
  setWaitCursor(status) {
    if (typeof status === 'undefined') {
      if (this.$waitPanel)
        this.$waitPanel.css({
          display: this.waitCursorCount > 0 ? 'initial' : 'none'
        });
    } else {
      switch (status) {
        case true:
          this.waitCursorCount++;
          break;
        case false:
          if (--this.waitCursorCount < 0)
            this.waitCursorCount = 0;
          break;
        case 'reset':
          this.waitCursorCount = 0;
          break;
      }
      this.setWaitCursor();
    }
  }

  /**
   * Sets the current value of the progress bar
   * @param {number} val - The current value. Should be less or equal than `max`. When -1, the progress bar will be hidden.
   * @param {number} [max] - Optional parameter representing the maximum value. When passed, the progress bar will be displayed.
   */
  setProgress(val, max) {
    if (this.$progress) {
      this.currentProgress = val;
      if (val < 0)
        this.$progress.css({ display: 'none' });
      else {
        if (max) {
          this.maxProgress = max;
          this.$progress.attr('max', max).css({ display: 'initial' });
        }
        this.$progress.attr('value', val);
      }
      log('trace', `Progress: ${this.currentProgress}/${this.maxProgress}`);
    }
  }

  /**
   * Increments the progress bar value by the specified amount, only when the progress bar is running.
   * @param {number} [val] - The amount to increment. When not defined, it's 1.
   */
  incProgress(val) {
    if (this.currentProgress >= 0)
      this.setProgress(this.currentProgress + (val || 1));
  }

  /**
   * Shows a window with clues or help for the current activity
   * @param {external:jQuery} _$hlpComponent - A JQuery DOM element with the information to be shown.
   * It can be a string or number. When `null`, the help window (if any) must be closed.
   */
  showHelp(_$hlpComponent) {
    // TODO: Implement HelpWindow
  }

  /**
   * Shows a "dialog" panel, useful for displaying information or prompt something to users
   * @param {boolean} modal - When `true`, the dialog should be closed by any click outside the main panel
   * @param {object} options - This object should have two components: `main` and `bottom`, both
   * containing a jQuery HTML element (or array of elements) to be placed on the main and bottom panels
   * of the dialog.
   * @returns {external:Promise} - A Promise that will be fulfilled when the dialog is closed.
   */
  showDlg(modal, options) {
    return new Promise((resolve, reject) => {
      this._dlgOkValue = 'ok';
      this._dlgCancelValue = 'cancelled';
      this._isModalDlg = modal;

      this.$dlgMainPanel.children().detach();
      this.$dlgBottomPanel.children().detach();
      if (options.main)
        this.$dlgMainPanel.append(options.main);
      if (options.bottom)
        this.$dlgBottomPanel.append(options.bottom);

      this._closeDlg = resolved => {
        if (resolved && resolve)
          resolve(this._dlgOkValue);
        else if (!resolved && reject)
          reject(this._dlgCancelValue);
        this.$dlgOverlay.css({ display: 'none' });
        this.enableMainButtons(true);
        this._closeDlg = Skin.prototype._closeDlg;
      };
      this.enableMainButtons(false);
      this.$dlgOverlay.css({ display: 'initial' });
    });
  }

  /**
   * Enables or disables the `tabindex` attribute of the main buttons. Useful when a modal dialog
   * overlay is active, to avoid direct access to controls not related with the dialog.
   * @param {boolean} status - `true` to make main controls navigable, `false` otherwise
   */
  enableMainButtons(status) {
    this.$playerCnt.find('button').attr('tabindex', status ? '0' : '-1');
  }

  /**
   * Called when the dialog must be closed, usually only by Skin members.
   * This method is re-defined on each call to `showDlg`, so the `resolve` and `reject`
   * functions can be safely called.
   */
  _closeDlg() {
    // Do nothing
  }

  /**
   * Displays a dialog with a report of the current results achieved by the user.
   * @param {module:report/Reporter.Reporter} reporter - The reporter system currently in use
   * @returns {external:Promise} - The Promise returned by {@link module:skins/Skin.Skin.showDlg}.
   */
  showReports(reporter) {
    this.$reportsPanel.html(this.$printReport(reporter));
    return this.showDlg(false, {
      main: [this.$infoHead, this.$reportsPanel],
      bottom: [this.$copyBtn, this.$closeDlgBtn]
    });
  }

  /**
   * Formats the current report in a DOM tree, ready to be placed in `$reportsPanel`
   * @param {module:report/Reporter.Reporter} reporter - The reporter system currently in use
   * @returns {external:jQuery[]} - An array of jQuery objects containing the full report
   */
  $printReport(reporter) {
    let result = [];
    if (reporter) {
      const
        report = reporter.getData(),
        started = new Date(report.started);

      result.push($('<div/>', { class: 'subTitle', id: this.ps.getUniqueId('ReportsLb') }).html(getMsg('Current results')));

      const $t = $('<table/>', { class: 'JCGlobalResults' });
      $t.append(
        $HTML.doubleCell(
          getMsg('Session started:'),
          `${started.toLocaleDateString()} ${started.toLocaleTimeString()}`),
        $HTML.doubleCell(
          getMsg('Reports system:'),
          `${getMsg(report.descriptionKey)} ${report.descriptionDetail}`));
      if (report.userId)
        $t.append($HTML.doubleCell(
          getMsg('User:'),
          report.userId));
      else if (report.user) // SCORM user
        $t.append($HTML.doubleCell(
          getMsg('User:'),
          report.user));

      if (report.sequences > 0) {
        if (report.sessions.length > 1)
          $t.append($HTML.doubleCell(
            getMsg('Projects:'),
            report.sessions.length));
        $t.append(
          $HTML.doubleCell(
            getMsg('Sequences:'),
            report.sequences),
          $HTML.doubleCell(
            getMsg('Activities done:'),
            report.activitiesDone),
          $HTML.doubleCell(
            getMsg('Activities played at least once:'),
            `${report.playedOnce}/${report.reportable} (${getPercent(report.ratioPlayed / 100)})`));
        if (report.activitiesDone > 0) {
          $t.append($HTML.doubleCell(
            getMsg('Activities solved:'),
            `${report.activitiesSolved} (${getPercent(report.ratioSolved / 100)})`));
          if (report.actScore > 0)
            $t.append(
              $HTML.doubleCell(
                getMsg('Partial score:'),
                `${getPercent(report.partialScore / 100)} ${getMsg('(out of played activities)')}`),
              $HTML.doubleCell(
                getMsg('Global score:'),
                `${getPercent(report.globalScore / 100)} ${getMsg('(out of all project activities)')}`));
          $t.append(
            $HTML.doubleCell(
              getMsg('Total time in activities:'),
              getHMStime(report.time * 1000)),
            $HTML.doubleCell(
              getMsg('Actions done:'),
              report.actions));
        }
        result.push($t);

        report.sessions.forEach(sr => {
          if (sr.sequences.length > 0) {
            const $t = $('<table/>', { class: 'JCDetailed' });
            result.push($('<p/>').html(report.sessions.length > 1 ? `${getMsg('Project')} ${sr.projectName}` : ''));
            $t.append($('<thead/>').append($('<tr/>').append(
              $HTML.th(getMsg('sequence')),
              $HTML.th(getMsg('activity')),
              $HTML.th(getMsg('OK')),
              $HTML.th(getMsg('actions')),
              $HTML.th(getMsg('score')),
              $HTML.th(getMsg('time')))));

            sr.sequences.forEach(seq => {
              let $tr = $('<tr/>').append($('<td/>', { rowspan: seq.activities.length }).html(seq.sequence));
              seq.activities.forEach(act => {
                if (act.closed) {
                  $tr.append($HTML.td(act.name));
                  $tr.append(act.solved ? $HTML.td(getMsg('YES'), 'ok') : $HTML.td(getMsg('NO'), 'no'));
                  $tr.append($HTML.td(act.actions));
                  $tr.append($HTML.td(getPercent(act.precision / 100)));
                  $tr.append($HTML.td(getHMStime(act.time * 1000)));
                } else {
                  $tr.append($HTML.td(act.name, 'incomplete'));
                  for (let r = 0; r < 4; r++)
                    $tr.append($HTML.td('-', 'incomplete'));
                }
                $t.append($tr);
                $tr = $('<tr/>');
              });
            });

            $t.append($('<tr/>').append(
              $HTML.td(getMsg('Total:')),
              $HTML.td(`${sr.played} (${getPercent(sr.ratioPlayed / 100)})`),
              $HTML.td(`${sr.solved} (${getPercent(sr.ratioSolved / 100)})`),
              $HTML.td(sr.actions),
              $HTML.td(getPercent(sr.score / 100)),
              $HTML.td(getHMStime(sr.time * 1000))));

            result.push($t);
          }
        }, this);
      } else
        result.push($('<p/>').html(getMsg('No activities done!')));
    }
    return result;
  }

  /**
   * Enables or disables a specific counter
   * @param {string} counter - Which counter
   * @param {boolean} bEnabled - When `true`, the counter will be enabled.
   */
  enableCounter(counter, bEnabled) {
    if (this.counters[counter])
      this.counters[counter].setEnabled(bEnabled);
  }

  /**
   * Main method used to build the content of the skin. Resizes and places internal objects.
   */
  doLayout() {
    // Resize player
    this.player.doLayout();

    // Build ths canvas at the end of current thread, thus avoiding
    // invalid sizes due to incomplete layout of DOM objects
    if (this.$msgBoxDiv)
      window.setTimeout(() => {

        // Temporary remove canvas to let div get its natural size:
        if (this.$msgBoxDivCanvas)
          this.$msgBoxDivCanvas.remove();

        // Get current size of message box div without canvas
        const
          msgWidth = this.$msgBoxDiv.outerWidth(),
          msgHeight = this.$msgBoxDiv.outerHeight();

        // Replace existing canvas if size has changed
        if (this.$msgBoxDivCanvas === null ||
          this.msgBox.dim.widht !== msgWidth ||
          this.msgBox.dim.height !== msgHeight) {
          this.$msgBoxDivCanvas = $(`<canvas width="${msgWidth}" height="${msgHeight}"/>`);
          this.msgBox.setBounds(new Rectangle(0, 0, msgWidth + 1, msgHeight));
          this.msgBox.buildAccessibleElement(this.$msgBoxDivCanvas, this.$msgBoxDiv);
        }
        // restore canvas
        this.$msgBoxDiv.append(this.$msgBoxDivCanvas);
        this.updateContent();
      }, 0);
  }

  /**
   * adjusts the skin to the dimension of its `$div` container
   * @returns {module:AWT.Dimension} the new dimension of the skin
   */
  fit() {
    this.doLayout();
    return new Dimension(this.$div.width(), this.$div.height());
  }

  /**
   * Sets or unsets the player in fullscreen mode, when allowed, using the
   * {@link https://github.com/sindresorhus/screenfull.js|screenfull.js} library.
   * @param {boolean} status - Whether to set or unset the player in fullscreen mode. When `null`
   * or `undefined`, the status toggles between fullscreen and windowed modes.
   * @returns {boolean} `true` if the request was successful, `false` otherwise.
   */
  setScreenFull(status) {
    if (document && document.fullscreenEnabled && (
      status === true && !document.fullscreenElement ||
      status === false && !document.fullscreenElement ||
      status !== true && status !== false)) {
      // Save current value of fullScreen for later use
      const full = document.fullscreenElement ? true : false;
      if (!document.fullscreenElement) {
        const element = this.player.$mainContainer.get(-1);
        if (element && element.requestFullscreen)
          element.requestFullscreen();
      } else {
        if (document.exitFullscreen) {
          document.exitFullscreen();
        }
      }
      this.player.fullScreenChecked = true;
      // Firefox don't updates `document.fullscreenElement` in real time, so use the saved value instead
      this.setSkinSizes(!full);
    }
  }

  /**
   * Method used to notify this skin that a specific action has changed its enabled/disabled status
   * @param {module:AWT.Action} _action - The action originating the change event
   */
  actionStatusChanged(act) {
    if (act.name && this.buttons[act.name])
      this.setEnabled(this.buttons[act.name], act.enabled);
  }

  /**
   * Enables or disables an object
   * @param {external:jQuery} $object - A JQuery DOM element
   * @override
   * @param {boolean} enabled
   */
  setEnabled($object, enabled) {
    if ($object && enabled)
      $object.removeAttr('disabled');
    else if ($object)
      $object.attr('disabled', true);
  }

  /**
   * Compares two Skin objects
   * @param {module:skins/Skin.Skin} skin - The Skin to compare against this
   * @returns {boolean} - `true` if both skins are equivalent.
   */
  equals(skin) {
    return skin &&
      this.name === skin.name &&
      this.ps === skin.ps;
  }

  /**
   * Gets the {@link module:boxes/ActiveBox.ActiveBox ActiveBox} used to display the main messages of activities
   * @returns {module:boxes/ActiveBox.ActiveBox}
   */
  getMsgBox() {
    return this.msgBox;
  }
}

/**
 * Collection of realized __Skin__ objects.
 * @type {module:skins/Skin.Skin[]}
 */
Skin.skinStack = [];

/**
 * Collection of skin style sheets already registered on the current document
 * @type {object}
 */
Skin.rootStyles = {};

/**
 * Counter used to label root nodes with unique IDs
 * @type {number}
 */
Skin.lastId = 1;

/**
 * List of classes derived from Skin. It should be filled by real skin classes at declaration time.
 * @type {object}
 */
Skin.CLASSES = {};

Object.assign(Skin.prototype, {
  /**
   * Class name of this skin. It will be used as a base selector in the definition of all CSS styles.
   * @name module:skins/Skin.Skin#skinId
   * @type {string} */
  skinId: 'JClicBasicSkin',
  /**
   * The HTML div object used by this Skin
   * @name module:skins/Skin.Skin#$div
   * @type {external:jQuery} */
  $div: null,
  /**
   * The HTML div where JClic Player will be placed
   * @name module:skins/Skin.Skin#$playerCnt
   * @type {external:jQuery} */
  $playerCnt: null,
  /**
   * Current name of the skin.
   * @name module:skins/Skin.Skin#name
   * @type {string} */
  name: 'default',
  /**
   * Specific options of this skin
   * @name module:skins/Skin.Skin#options
   * @type {object} */
  options: {},
  /**
   * Waiting panel, displayed while loading resources.
   * @name module:skins/Skin.Skin#$waitPanel
   * @type {external:jQuery} */
  $waitPanel: null,
  /**
   * Graphic indicator of loading progress
   * @name module:skins/Skin.Skin#$progress
   * @type {external:jQuery} */
  $progress: null,
  /**
   * Current value of the progress bar
   * @name module:skins/Skin.Skin#currentProgress
   * @type {number} */
  currentProgress: -1,
  /**
   * Max value of the progress bar
   * @name module:skins/Skin.Skin#maxProgress
   * @type {number} */
  maxProgress: 0,
  /**
   * The box used to display the main messages of JClic activities
   * @name module:skins/Skin.DefaultSkin#msgBox
   * @type {module:boxes/ActiveBox.ActiveBox} */
  msgBox: null,
  /**
   * The `div` DOM object where `msgBox` is located
   * @name module:skins/Skin.DefaultSkin#$msgBoxDiv
   * @type {external:jQuery} */
  $msgBoxDiv: null,
  /*
   * An HTML `canvas` object created in `$msgBoxDiv`
   * @name module:skins/Skin.DefaultSkin#$msgBoxDivCanvas
   * @type {external:jQuery} */
  $msgBoxDivCanvas: null,
  /**
   * Main panel used to display modal and non-modal dialogs
   * @name module:skins/Skin.Skin#$dlgOverlay
   * @type {external:jQuery} */
  $dlgOverlay: null,
  /**
   * Main panel of dialogs, where relevant information must be placed
   * @name module:skins/Skin.Skin#$dlgMainPanel
   * @type {external:jQuery} */
  $dlgMainPanel: null,
  /**
   * Bottom panel of dialogs, used for action buttons
   * @name module:skins/Skin.Skin#$dlgBottomPanel
   * @type {external:jQuery} */
  $dlgBottomPanel: null,
  /**
   * Element usually used as header in dialogs, with JClic logo, name and version
   * @name module:skins/Skin.Skin#infoHead
   * @type {external:jQuery} */
  $infoHead: null,
  /**
   * Iconic button used to copy content to clipboard
   * @name module:skins/Skin.Skin#$copyBtn
   * @type {external:jQuery} */
  $copyBtn: null,
  /**
   * Iconic button used to close the dialog
   * @name module:skins/Skin.Skin#$closeDlgBtn
   * @type {external:jQuery} */
  $closeDlgBtn: null,
  /**
   * OK dialog button
   * @name module:skins/Skin.Skin#$okDlgBtn
   * @type {external:jQuery} */
  $okDlgBtn: null,
  /**
   * Cancel dialog button
   * @name module:skins/Skin.Skin#$cancelDlgBtn
   * @type {external:jQuery} */
  $cancelDlgBtn: null,
  /**
   * Value to be returned by the dialog promise when the presented task is fulfilled
   * @name module:skins/Skin.Skin#_dlgOkValue
   * @type {object} */
  _dlgOkValue: null,
  /**
   * Value to be returned in user-canceled dialogs
   * @name module:skins/Skin.Skin#_dlgCancelValue
   * @type {object} */
  _dlgCancelValue: null,
  /**
   * Flag indicating if the current dialog is modal or not
   * @name module:skins/Skin.Skin#_isModalDlg
   * @type {boolean} */
  _isModalDlg: false,
  /**
   * Div inside {@link module:skins/Skin.Skin#$dlgOverlay $dlgOverlay} where JClicPlayer will place the information to be shown
   * @name module:skins/Skin.Skin#$reportsPanel
   * @type {external:jQuery} */
  $reportsPanel: null,
  /**
   * The basic collection of buttons that most skins implement
   * @name module:skins/Skin.Skin#buttons
   * @type {object} */
  buttons: {
    'prev': null,
    'next': null,
    'return': null,
    'reset': null,
    'info': null,
    'help': null,
    'audio': null,
    'about': null,
    'fullscreen': null,
    'close': null
  },
  /**
   * The collection of counters
   * @name module:skins/Skin.Skin#counters
   * @type {object} */
  counters: {
    'actions': null,
    'score': null,
    'time': null
  },
  /**
   * The collection of message areas
   * @name module:skins/Skin.Skin#msgArea
   * @type {object} */
  msgArea: {
    'main': null,
    'aux': null,
    'mem': null
  },
  /**
   * The {@link module:JClicPlayer.JClicPlayer JClicPlayer} object associated to this skin
   * @name module:skins/Skin.Skin#player
   * @type {module:JClicPlayer.JClicPlayer} */
  player: null,
  /**
   * The {@link http://projectestac.github.io/jclic/apidoc/edu/xtec/jclic/PlayStation.html|PlayStation}
   * used by this Skin. Usually, the same as `player`
   * @name module:skins/Skin.Skin#ps
   * @type {module:JClicPlayer.JClicPlayer} */
  ps: null,
  /**
   * Counter to be incremented or decremented as `waitCursor` is requested or released.
   * @name module:skins/Skin.Skin#waitCursorCount
   * @type {number} */
  waitCursorCount: 0,
  //
  // Buttons and other graphical resources used by this skin.
  //
  /**
   * Main styles
   * @name module:skins/Skin.Skin#basicCSS
   * @type {string} */
  basicCSS,
  /**
   * Waiting screen styles
   * @name module:skins/Skin.Skin#waitAnimCSS
   * @type {string} */
  waitAnimCSS,
  /**
   * Animated image displayed while loading resources
   * Based on Ryan Allen's [svg-spinner](http://articles.dappergentlemen.com/2015/01/13/svg-spinner/)
   * @name module:skins/Skin.Skin#waitImgBig
   * @type {string} */
  waitImgBig,
  /**
   * Animated image displayed while loading resources (small)
   * @name module:skins/Skin.Skin#waitImgSmall
   * @type {string} */
  waitImgSmall,
  /**
   * Reports screen styles
   * @name module:skins/Skin.Skin#reportsCSS
   * @type {string} */
  reportsCSS,
  //
  // Icons used in buttons:
  //
  /**
   * Icon for 'close dialog' button
   * @name module:skins/Skin.Skin#closeDialogIcon
   * @type {string} */
  closeDialogIcon,
  /**
   * Icon for 'ok' button
   * @name module:skins/Skin.Skin#okDialogIcon
   * @type {string} */
  okDialogIcon,
  /**
   * Icon for 'copy' button
   * @name module:skins/Skin.Skin#copyIcon
   * @type {string} */
  copyIcon,
  /**
   * JClic logo
   * @name module:skins/Skin.Skin#appLogo
   * @type {string} */
  appLogo,
  /**
   * Screen sizes (width and height) below which will half sized elements will be used
   * @name module:skins/Skin.DefaultSkin#halfMedia
   * @type {object} */
  halfMedia: { width: 376, height: 282 },
  /**
   * Screen sizes (width and height) below which will two-thirds sized elements will be used
   * @name module:skins/Skin.DefaultSkin#twoThirdsMedia
   * @type {object} */
  twoThirdsMedia: { width: 420, height: 315 },
});

export default Skin;