Activity.js

/**
 *  File    : Activity.js
 *  Created : 07/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 window */

import $ from 'jquery';
import { settings, log, getMsg, attrForEach, nSlash, getBoolean, getXmlText, checkColor, isNullOrUndef, getAttr, setAttr } from './Utils.js';
import { Rectangle, Gradient, Point, Dimension, Container } from './AWT.js';
import EventSounds from './media/EventSounds.js';
import ActiveBoxContent from './boxes/ActiveBoxContent.js';
import ActiveBagContent from './boxes/ActiveBagContent.js';
import BoxBase from './boxes/BoxBase.js';
import AutoContentProvider from './automation/AutoContentProvider.js';
import TextGridContent from './boxes/TextGridContent.js';
import Evaluator from './activities/text/Evaluator.js';
import TextActivityDocument from './activities/text/TextActivityDocument.js';

// Event used for detecting touch devices
const TOUCH_TEST_EVENT = 'touchstart';

/**
 * Activity is the abstract base class of JClic activities. It defines also the inner class
 * {@link module:Activity.ActivityPanel ActivityPanel}, wich is responsible for user interaction with the activity
 * content.
 * Activities should extend both `Activity` and `ActivityPanel` classes in order to become fully
 * operative.
 * @abstract
 */
export class Activity {
  /**
   * Activity constructor
   * @param {module:project/JClicProject.JClicProject} project - The {@link module:project/JClicProject.JClicProject JClicProject} to which this activity belongs
   */
  constructor(project) {
    this.project = project;
    this.eventSounds = new EventSounds(this.project.settings.eventSounds);
    this.messages = {};
    this.abc = {};
  }

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

  /**
   * Factory constructor that returns a specific type of Activity based on the `class` attribute
   * declared in `data`.
   * @param {object|external:jQuery} data - Can be a jQuery XML element, or an object obtained with a call to `getAttributes`
   * @param {module:project/JClicProject.JClicProject} project - The {@link module:project/JClicProject.JClicProject JClicProject} to which this activity belongs
   * @returns {module:Activity.Activity}
   */
  static getActivity(data, project) {
    let act = null;
    const isXml = data.jquery && true;
    if (data && project) {
      const className = isXml ? (data.attr('class') || '').replace(/^edu\.xtec\.jclic\.activities\./, '@') : data.className;
      const cl = Activity.CLASSES[className];
      if (cl) {
        act = new cl(project);
        if (isXml)
          act.setProperties(data);
        else
          act.setAttributes(data);
      } else
        log('error', `Unknown activity class: ${className}`);
    }
    return act;
  }

  /**
   * Loads this object settings from an XML element
   * @param {external:jQuery} $xml - The jQuery XML element to parse
   */
  setProperties($xml) {

    // Read attributes
    attrForEach($xml.get(0).attributes, (name, val) => {
      switch (name) {
        // Generic attributes:
        case 'name':
          val = nSlash(val);
        /* falls through */
        case 'code':
        case 'type':
        case 'description':
          this[name] = val;
          break;

        case 'class':
          this.className = val.replace(/^edu\.xtec\.jclic\.activities\./, '@');
          break;

        case 'inverse':
          this.invAss = getBoolean(val, false);
          break;

        case 'autoJump':
        case 'forceOkToAdvance':
        case 'amongParagraphs':
          this[name] = getBoolean(val, false);
          break;
      }
    });

    // Read specific nodes
    $xml.children().each((_n, child) => {
      const $node = $(child);
      switch (child.nodeName) {
        case 'settings':
          // Read more attributes
          attrForEach($node.get(0).attributes, (name, val) => {
            switch (name) {
              case 'infoUrl':
              case 'infoCmd':
                this[name] = val;
                break;

              case 'margin':
              case 'maxTime':
              case 'maxActions':
                this[name] = Number(val);
                break;

              case 'report':
                this.includeInReports = getBoolean(val, false);
                break;
              case 'countDownTime':
              case 'countDownActions':
              case 'reportActions':
              case 'useOrder':
              case 'dragCells':
                this[name] = getBoolean(val, false);
                break;
            }
          });

          // Read elements of _settings_
          $node.children().each((_n, child) => {
            const $node = $(child);
            switch (child.nodeName) {
              case 'skin':
                this.skinFileName = $node.attr('file');
                break;

              case 'helpWindow':
                this.helpMsg = getXmlText(this);
                this.showSolution = getBoolean($node.attr('showSolution'), false);
                this.helpWindow = this.helpMsg !== null || this.showSolution;
                break;

              case 'container':
                // Read settings related to the 'container'
                // (the main panel containing the activity and other elements)
                this.bgColor = checkColor($node.attr('bgColor'), settings.BoxBase.BACK_COLOR);

                $node.children().each((_n, child) => {
                  const $child = $(child);
                  switch (child.nodeName) {
                    case 'image':
                      this.bgImageFile = $child.attr('name');
                      this.tiledBgImg = getBoolean($child.attr('tiled'), false);
                      break;
                    case 'counters':
                      this.bTimeCounter = getBoolean($child.attr('time'), true);
                      this.bActionsCounter = getBoolean($child.attr('actions'), true);
                      this.bScoreCounter = getBoolean($child.attr('score'), true);
                      break;
                    case 'gradient':
                      this.bgGradient = new Gradient().setProperties($child);
                      break;
                  }
                });
                break;

              case 'window':
                // Read settings related to the 'window'
                // (the panel where the activity deploys its content)
                this.activityBgColor = checkColor($node.attr('bgColor'), settings.DEFAULT_BG_COLOR);
                this.transparentBg = getBoolean($node.attr('transparent'), false);
                this.border = getBoolean($node.attr('border'), false);
                $node.children().each((_n, child) => {
                  const $child = $(child);
                  switch (child.nodeName) {
                    case 'gradient':
                      this.activityBgGradient = new Gradient().setProperties($child);
                      break;
                    case 'position':
                      this.absolutePosition = new Point().setProperties($child);
                      this.absolutePositioned = true;
                      break;
                    case 'size':
                      this.windowSize = new Dimension().setProperties($child);
                      break;
                  }
                });
                break;

              case 'eventSounds':
                // eventSounds is already created in constructor,
                // just read properties
                this.eventSounds.setProperties($node);
                break;
            }
          });
          break;

        case 'messages':
          $node.children('cell').each((_n, child) => {
            const m = this.readMessage($(child));
            // Possible message types are: `initial`, `final`, `previous`, `finalError`
            this.messages[m.type] = m;
          });
          break;

        case 'automation':
          // Read the automation settings ('Arith' or other automation engines)
          this.acp = AutoContentProvider.getProvider($node, this.project);
          if (this.acp)
            this.numericContent = this.acp.numericContent;
          break;

        // Settings specific to panel-type activities (puzzles, associations...)
        case 'cells':
          // Read the [ActiveBagContent](ActiveBagContent.html) objects
          const cellSet = new ActiveBagContent().setProperties($node, this.project.mediaBag);
          // Valid ids:
          // - Panel activities: 'primary', 'secondary', solvedPrimary'
          // - Textpanel activities: 'acrossClues', 'downClues', 'answers'
          this.abc[cellSet.id] = cellSet;
          break;

        case 'scramble':
          // Read the 'shuffle' mode
          this.shuffles = Number($node.attr('times'));
          this.shuffleA = getBoolean($node.attr('primary'));
          this.shuffleB = getBoolean($node.attr('secondary'));
          break;

        case 'layout':
          attrForEach($node.get(0).attributes, (name, value) => {
            switch (name) {
              case 'position':
                this.boxGridPos = value;
                break;
              case 'wildTransparent':
              case 'upperCase':
              case 'checkCase':
                this[name] = getBoolean(value);
            }
          });
          break;

        // Element specific to 'Menu' activities:
        case 'menuElement':
          this.menuElements.push({
            caption: $node.attr('caption') || '',
            icon: $node.attr('icon') || null,
            projectPath: $node.attr('path') || null,
            sequence: $node.attr('sequence') || null,
            description: $node.attr('description') || ''
          });
          break;

        // Element specific to 'CrossWord' and
        // 'WordSearch' activities:
        case 'textGrid':
          // Read the 'textGrid' element into a 'TextGridContent'
          this.tgc = new TextGridContent().setProperties($node);
          break;

        // Read the clues of 'WordSearch' activities
        case 'clues':
          // Read the array of clues
          this.clues = [];
          this.clueItems = [];
          $node.children('clue').each((n, child) => {
            this.clueItems[n] = Number($(child).attr('id'));
            this.clues[n] = child.textContent;
          });
          break;

        // Elements specific to text activities:
        case 'checkButton':
          this.checkButtonText = child.textContent || 'check';
          break;

        case 'prevScreen':
          this.prevScreen = true;
          this.prevScreenMaxTime = $node.attr('maxTime') || -1;
          $node.children().each((_n, child) => {
            switch (child.nodeName) {
              case 'style':
                this.prevScreenStyle = new BoxBase().setProperties($(child));
                break;
              case 'p':
                if (this.prevScreenText === null)
                  this.prevScreenText = '';
                this.prevScreenText += `<p>${child.textContent}</p>`;
                break;
            }
          });
          break;

        case 'evaluator':
          this.ev = Evaluator.getEvaluator($node);
          break;

        case 'document':
          // Read main document of text activities
          this.document = new TextActivityDocument().setProperties($node, this.project.mediaBag);
          break;
      }
    });
    return this;
  }

  /**
   * Read an activity message from an XML element
   * @param {external:jQuery} $xml - The XML element to be parsed
   * @returns {module:boxes/ActiveBoxContent.ActiveBoxContent}
   */
  readMessage($xml) {
    const msg = new ActiveBoxContent().setProperties($xml, this.project.mediaBag);
    //
    // Allowed types are: `initial`, `final`, `previous`, `finalError`
    msg.type = $xml.attr('type');
    if (isNullOrUndef(msg.style))
      msg.style = new BoxBase(null);
    return msg;
  }

  /**
   * Gets a object with the basic attributes needed to rebuild this instance excluding functions,
   * parent references, constants and also attributes retaining the default value.
   * The resulting object is commonly usued to serialize elements in JSON format.
   * @returns {object} - The resulting object, with minimal attrributes
   */
  getAttributes() {
    return getAttr(this, [
      'name', 'className', 'code', 'type', 'description',
      'invAss', 'numericContent',
      'autoJump', 'forceOkToAdvance', 'amongParagraphs',
      'infoUrl', 'infoCmd',
      `margin|${settings.DEFAULT_MARGIN}`, 'maxTime', 'maxActions',
      'includeInReports|true', 'reportActions|false',
      'countDownTime', 'countDownActions',
      'useOrder', 'dragCells',
      'skinFileName',
      'showSolution|false', 'helpMsg',
      `bgColor|${settings.DEFAULT_BG_COLOR}`, 'bgImageFile', 'tiledBgImg',
      'bTimeCounter|true', 'bActionsCounter|true', 'bScoreCounter|true',
      `activityBgColor|${settings.DEFAULT_BG_COLOR}`, 'transparentBg|false', 'border|true',
      'shuffleA', 'shuffleB', 'shuffles', 'boxGridPos',
      'wildTransparent', 'upperCase', 'checkCase',
      'checkButtonText',
      'prevScreen', 'prevScreenMaxTime', 'prevScreenText',
      'bgGradient', 'activityBgGradient', // Gradient
      'absolutePosition', // Point
      'windowSize', // Dimension
      'eventSounds', // EventSounds
      'messages', // ActiveBoxContent{}
      'acp', // AutoContentProvider
      'abc', // ActiveBagContent{}
      'menuElements', // Activity~menuElement
      'tgc', // TextGridContent
      'clues', // string[]
      'clueItems', // number[]
      'prevScreenStyle', // BoxBase
      'ev', // Evaluator
      'document', // TextActivityDocument
    ]);
  }

  /**
   * Load the activity settings from a data object
   * @param {object} data - The data object to parse
   */
  setAttributes(data, mediaBag = this.project.mediaBag) {
    setAttr(this, data, [
      'name', 'className', 'code', 'type', 'description', 'invAss', 'numericContent',
      'autoJump', 'forceOkToAdvance', 'amongParagraphs', 'infoUrl', 'infoCmd',
      'margin', 'maxTime', 'maxActions', 'includeInReports', 'reportActions',
      'countDownTime', 'countDownActions', 'useOrder', 'dragCells', 'skinFileName',
      'showSolution', 'helpMsg', 'bgColor', 'bgImageFile', 'tiledBgImg',
      'bTimeCounter', 'bActionsCounter', 'bScoreCounter',
      'activityBgColor', 'transparentBg', 'border',
      'shuffleA', 'shuffleB', 'shuffles', 'boxGridPos',
      'wildTransparent', 'upperCase', 'checkCase', 'checkButtonText',
      'prevScreen', 'prevScreenMaxTime', 'prevScreenText',
      { key: 'bgGradient', fn: Gradient },
      { key: 'activityBgGradient', fn: Gradient },
      { key: 'absolutePosition', fn: Point },
      { key: 'windowSize', fn: Dimension },
      { key: 'messages', fn: ActiveBoxContent, group: 'object', init: 'key', params: [mediaBag] },
      { key: 'abc', fn: ActiveBagContent, group: 'object', init: 'key', params: [mediaBag] },
      { key: 'acp', fn: AutoContentProvider, params: [mediaBag] },
      'menuElements',
      { key: 'tgc', fn: TextGridContent },
      'clues',
      'clueItems',
      { key: 'prevScreenStyle', fn: BoxBase },
      { key: 'ev', fn: Evaluator },
      { key: 'document', fn: TextActivityDocument, params: [mediaBag] },
    ]);

    // Reused objects
    if (data.eventSounds)
      this.eventSounds.setAttributes(data.eventSounds);

    // Manual settings
    if (this.absolutePosition)
      this.absolutePositioned = true;

    return this;
  }

  /**
   * Initialises the {@link module:automation/AutoContentProvider.AutoContentProvider AutoContentProvider}, when defined.
   */
  initAutoContentProvider() {
    if (this.acp !== null)
      this.acp.init();
  }

  /**
   * Preloads the media content of the activity.
   * @param {module:JClicPlayer.JClicPlayer} ps - The {@link module:JClicPlayer.JClicPlayer} used to realize the media objects.
   */
  prepareMedia(ps) {
    this.eventSounds.realize(ps, this.project.mediaBag);
    $.each(this.messages, (_key, msg) => {
      if (msg !== null) msg.prepareMedia(ps);
    });
    $.each(this.abc, (_key, abc) => {
      if (abc !== null)
        abc.prepareMedia(ps);
    });
    return true;
  }

  /**
   * Whether the activity allows the user to request the solution.
   * @returns {boolean}
   */
  helpSolutionAllowed() {
    return false;
  }

  /**
   * Whether the activity allows the user to request help.
   * @returns {boolean}
   */
  helpWindowAllowed() {
    return this.helpWindow &&
      (this.helpSolutionAllowed() && this.showSolution || this.helpMsg !== null);
  }

  /**
   * Retrieves the minimum number of actions needed to solve this activity.
   * @returns {number}
   */
  getMinNumActions() {
    return 0;
  }

  /**
   * When this method returns `true`, the automatic jump to the next activity must be paused at
   * this activity.
   * @returns {boolean}
   */
  mustPauseSequence() {
    return this.getMinNumActions() !== 0;
  }

  /**
   * Whether or not the activity can be reset
   * @returns {boolean}
   */
  canReinit() {
    return true;
  }

  /**
   * Whether or not the activity has additional information to be shown.
   * @returns {boolean}
   */
  hasInfo() {
    return this.infoUrl !== null && this.infoUrl.length > 0 ||
      this.infoCmd !== null && this.infoCmd.length > 0;
  }

  /**
   * Whether or not the activity uses random to shuffle internal components
   * @returns {boolean}
   */
  hasRandom() {
    return false;
  }

  /**
   * When `true`, the activity must always be shuffled
   * @returns {boolean}
   */
  shuffleAlways() {
    return false;
  }

  /**
   * When `true`, the activity makes use of the keyboard
   * @returns {boolean}
   */
  needsKeyboard() {
    return false;
  }

  /**
   * Called when the activity must be disposed
   */
  end() {
    this.eventSounds.close();
    this.clear();
  }

  /**
   * Called when the activity must reset its internal components
   */
  clear() {
  }

  /**
   *
   * Getter method for `windowSize`
   * @returns {module:AWT.Dimension}
   */
  getWindowSize() {
    return new Dimension(this.windowSize);
  }

  /**
   * Setter method for `windowSize`
   * @param {module:AWT.Dimension} windowSize
   */
  setWindowSize(windowSize) {
    this.windowSize = new Dimension(windowSize);
  }

  /**
   * Builds the {@link module:Activity.ActivityPanel ActivityPanel} object.
   * Subclasses must update the `Panel` member of its prototypes to produce specific panels.
   * @param {module:JClicPlayer.JClicPlayer} ps - The {@link module:JClicPlayer.JClicPlayer JClicPlayer} used to build media objects.
   * @returns {module:Activity.ActivityPanel}
   */
  getActivityPanel(ps) {
    return new this.constructor.Panel(this, ps);
  }
}

/**
 * Classes derived from `Activity` should register themselves by adding a field to
 * `Activity.CLASSES` using `Activity.registerClass`
 * @type {object}
 */
Activity.CLASSES = {
  '@panels.Menu': Activity
};

Object.assign(Activity.prototype, {
  /**
   * The {@link module:project/JClicProject.JClicProject JClicProject} to which this activity belongs
   * @name module:Activity.Activity#project
   * @type {module:project/JClicProject.JClicProject} */
  project: null,
  /**
   * The Activity name
   * @name module:Activity.Activity#name
   * @type {string} */
  name: settings.DEFAULT_NAME,
  /**
   * The class name of this Activity
   * @name module:Activity.Activity#className
   * @type {string} */
  className: null,
  /**
   * Code used in reports to filter queries. Default is `null`.
   * @name module:Activity.Activity#code
   * @type {string} */
  code: null,
  /**
   * Type of activity, used in text activities to distinguish between different variants of the
   * same activity. Possible values are: `orderWords`, `orderParagraphs`, `identifyWords` and
   * `identifyChars`.
   * @name module:Activity.Activity#type
   * @type {string} */
  type: null,
  /**
   * A short description of the activity
   * @name module:Activity.Activity#description
   * @type {string} */
  description: null,
  /**
   * The space between the activity components measured in pixels.
   * @name module:Activity.Activity#margin
   * @type {number} */
  margin: settings.DEFAULT_MARGIN,
  /**
   * The background color of the activity panel
   * @name module:Activity.Activity#bgColor
   * @type {string} */
  bgColor: settings.DEFAULT_BG_COLOR,
  /**
   * When set, gradient used to draw the activity window background
   * @name module:Activity.Activity#bgGradient
   * @type {module:AWT.Gradient} */
  bgGradient: null,
  /**
   * Whether the bgImage (if any) has to be tiled across the panel background
   * @name module:Activity.Activity#tiledBgImg
   * @type {boolean} */
  tiledBgImg: false,
  /**
   * Filename of the image used as a panel background.
   * @name module:Activity.Activity#bgImageFile
   * @type {string} */
  bgImageFile: null,
  /**
   * Whether to draw a border around the activity panel
   * @name module:Activity.Activity#border
   * @type {boolean} */
  border: true,
  /**
   * Whether to place the activity panel at the point specified by `absolutePosition` or leave
   * it centered on the main player's window.
   * @name module:Activity.Activity#absolutePositioned
   * @type {boolean} */
  absolutePositioned: false,
  /**
   * The position of the activity panel on the player.
   * @name module:Activity.Activity#absolutePosition
   * @type {module:AWT.Point} */
  absolutePosition: null,
  /**
   * Whether to generate usage reports
   * @name module:Activity.Activity#includeInReports
   * @type {boolean} */
  includeInReports: true,
  /**
   * Whether to send action events to the {@link module:Reporter.Reporter Reporter}
   * @name module:Activity.Activity#reportActions
   * @type {boolean} */
  reportActions: false,
  /**
   * Whether to allow help about the activity or not.
   * @name module:Activity.Activity#helpWindow
   * @type {boolean} */
  helpWindow: false,
  /**
   * Whether to show the solution on the help window.
   * @name module:Activity.Activity#showSolution
   * @type {boolean} */
  showSolution: false,
  /**
   * Message to be shown in the help window when `showSolution` is `false`.
   * @name module:Activity.Activity#helpMsg
   * @type {string} */
  helpMsg: '',
  /**
   * Specific set of {@link module:media/EventSounds.EventSounds EventSounds} used in the activity. The default is `null`, meaning
   * to use the default event sounds.
   * @name module:Activity.Activity#eventSounds
   * @type {module:media/EventSounds.EventSounds} */
  eventSounds: null,
  /**
   * Wheter the activity must be solved in a specific order or not.
   * @name module:Activity.Activity#useOrder
   * @type {boolean} */
  useOrder: false,
  /**
   * Wheter the cells of the activity will be dragged across the screen.
   * When `false`, a line will be painted to link elements.
   * @name module:Activity.Activity#dragCells
   * @type {boolean} */
  dragCells: false,
  /**
   * File name of the Skin used by the activity. The default value is `null`, meaning that the
   * activity will use the skin specified for the project.
   * @name module:Activity.Activity#skinFileName
   * @type {string} */
  skinFileName: null,
  /**
   * Maximum amount of time (seconds) to solve the activity. The default value is 0, meaning
   * unlimited time.
   * @name module:Activity.Activity#maxTime
   * @type {number}*/
  maxTime: 0,
  /**
   * Whether the time counter should display a countdown when `maxTime > 0`
   * @name module:Activity.Activity#countDownTime
   * @type {boolean} */
  countDownTime: false,
  /**
   * Maximum number of actions allowed to solve the activity. The default value is 0, meaning
   * unlimited actions.
   * @name module:Activity.Activity#maxActions
   * @type {number}*/
  maxActions: 0,
  /**
   * Whether the actions counter should display a countdown when `maxActions > 0`
   * @name module:Activity.Activity#countDownActions
   * @type {boolean} */
  countDownActions: false,
  /**
   * URL to be launched when the user clicks on the 'info' button. Default is `null`.
   * @name module:Activity.Activity#infoUrl
   * @type {string} */
  infoUrl: null,
  /**
   * System command to be launched when the user clicks on the 'info' button. Default is `null`.
   * Important: this parameter is currently not being used
   * @name module:Activity.Activity#infoCmd
   * @type {string} */
  infoCmd: null,
  /**
   * The content of the initial, final, previous and error messages shown by the activity.
   * @name module:Activity.Activity#messages
   * @type {module:boxes/ActiveBoxContent.ActiveBoxContent[]} */
  messages: null,
  /**
   * Preferred dimension of the activity window
   * @name module:Activity.Activity#windowSize
   * @type {module:AWT.Dimension} */
  windowSize: new Dimension(settings.DEFAULT_WIDTH, settings.DEFAULT_HEIGHT),
  /**
   * Whether the activity window has transparent background.
   * @name module:Activity.Activity#transparentBg
   * @type {boolean} */
  transparentBg: false,
  /**
   * The background color of the activity
   * @name module:Activity.Activity#activityBgColor
   * @type {string} */
  activityBgColor: settings.DEFAULT_BG_COLOR,
  /**
   * Gradient used to draw backgrounds inside the activity.
   * @name module:Activity.Activity#activityBgGradient
   * @type {module:AWT.Gradient} */
  activityBgGradient: null,
  /**
   * Whether to display or not the 'time' counter
   * @name module:Activity.Activity#bTimeCounter
   * @type {boolean} */
  bTimeCounter: true,
  /**
   * Whether to display or not the 'score' counter
   * @name module:Activity.Activity#bScoreCounter
   * @type {boolean} */
  bScoreCounter: true,
  /**
   * Whether to display or not the 'actions' counter
   * @name module:Activity.Activity#bActionsCounter
   * @type {boolean} */
  bActionsCounter: true,
  /**
   * Special object used to generate random content at the start of the activity
   * @name module:Activity.Activity#acp
   * @type {module:automation/AutoContentProvider.AutoContentProvider} */
  acp: null,
  //
  // Fields used only in certain activity types
  // ------------------------------------------
  //
  /**
   * Array of bags with the description of the content to be displayed on panels and cells.
   * @name module:Activity.Activity#abc
   * @type {module:boxes/ActiveBagContent.ActiveBagContent[]} */
  abc: null,
  /**
   * Content of the grid of letters used in crosswords and shuffled letters
   * @name module:Activity.Activity#tgc
   * @type {module:boxes/TextGridContent.TextGridContent} */
  tgc: null,
  /**
   * The main document used in text activities
   * @name module:Activity.Activity#document
   * @type {module:activities/text/TextActivityDocument.TextActivityDocument} */
  document: null,
  /**
   * Relative position of the text grid (uses the same position codes as box grids)
   * @name module:Activity.Activity#boxGridPos
   * @type {string} */
  boxGridPos: 'AB',
  /**
   * Number of times to shuffle the cells at the beginning of the activity
   * @name module:Activity.Activity#shuffles
   * @type {number} */
  shuffles: settings.DEFAULT_SHUFFLES,
  /**
   * Box grid A must be shuffled.
   * @name module:Activity.Activity#shuffleA
   * @type {boolean} */
  shuffleA: true,
  /**
   * Box grid B must be shuffled.
   * @name module:Activity.Activity#shuffleB
   * @type {boolean} */
  shuffleB: true,
  /**
   * Flag to indicate "inverse resolution" in complex associations
   * @name module:Activity.Activity#invAss
   * @type {boolean} */
  invAss: false,
  /**
   * Array of menu elements, used in activities of type {@link module:activities/panels/Menu.Menu Menu}
   * @name module:Activity.Activity#menuElements
   * @type {object[]} */
  menuElements: null,
  /**
   * This activity uses numeric expressions, so text literals should be
   * converted to numbers for comparisions, taking in account the
   * number format of the current locale (dot or comma as decimal separator)
   * @name module:Activity.Activity#numericContent
   * @type {boolean} */
  numericContent: false,
});

/**
 * This object is responsible for rendering the contents of the activity on the screen and
 * managing user's interaction.
 * Each type of Activity must implement its own `ActivityPanel`.
 * In JClic, {@link http://projectestac.github.io/jclic/apidoc/edu/xtec/jclic/Activity.Panel.html Activity.Panel}
 * extends {@link http://docs.oracle.com/javase/7/docs/api/javax/swing/JPanel.html javax.swing.JPanel}.
 * On this implementation, the JPanel will be replaced by an HTML `div` tag.
 * @extends module:AWT.Container
 */
export class ActivityPanel extends Container {
  /**
   * ActivityPanel constructor
   * @param {module:Activity.Activity} act - The {@link module:Activity.Activity Activity} to which this Panel belongs
   * @param {module:JClicPlayer.JClicPlayer} ps - Any object implementing the methods defined in the
   * {@link http://projectestac.github.io/jclic/apidoc/edu/xtec/jclic/PlayStation.html PlayStation}
   * Java interface.
   * @param {external:jQuery} [$div] - The jQuery DOM element where this Panel will deploy
   */
  constructor(act, ps, $div) {
    // ActivityPanel extends Container
    super();
    this.act = act;
    this.ps = ps;
    this.minimumSize = new Dimension(100, 100);
    this.preferredSize = new Dimension(500, 400);
    if ($div)
      this.$div = $div;
    else
      this.$div = $('<div/>', { class: 'JClicActivity', 'aria-label': getMsg('Activity panel') });
    this.act.initAutoContentProvider();
  }

  /**
   * Sets the size and position of this activity panel
   * @param {module:AWT.Rectangle} rect
   */
  setBounds(rect) {
    this.pos.x = rect.pos.x;
    this.pos.y = rect.pos.y;
    this.dim.width = rect.dim.width;
    this.dim.height = rect.dim.height;

    this.invalidate(rect);
    this.$div.css({
      position: 'relative',
      left: rect.pos.x,
      top: rect.pos.y,
      width: rect.dim.width,
      height: rect.dim.height
    });
  }

  /**
   * Prepares the visual components of the activity
   */
  buildVisualComponents() {
    this.playing = false;
    this.skin = null;
    if (this.act.skinFileName && this.act.skinFileName.length > 0 && this.act.skinFileName !== this.act.project.settings.skinFileName)
      this.skin = this.act.project.mediaBag.getSkinElement(this.act.skinFileName, this.ps);

    this.bgImage = null;
    if (this.act.bgImageFile && this.act.bgImageFile.length > 0) {
      const mbe = this.act.project.mediaBag.getElement(this.act.bgImageFile, true);
      if (mbe)
        this.bgImage = mbe.data;
    }

    this.backgroundColor = this.act.activityBgColor;

    if (this.act.transparentBg)
      this.backgroundTransparent = true;

    // TODO: fix bevel-border type
    if (this.act.border)
      this.border = true;

    const cssAct = {
      display: 'block',
      'background-color': this.backgroundTransparent ? 'transparent' : this.backgroundColor
    };

    // Border shadow style Material Design, inspired in [http://codepen.io/Stenvh/pen/EaeWqW]
    if (this.border) {
      cssAct['box-shadow'] = '0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12)';
      cssAct['border-radius'] = '2px';
      cssAct['color'] = '#272727';
    }

    if (this.act.activityBgGradient)
      cssAct['background-image'] = this.act.activityBgGradient.getCss();

    this.$div.css(cssAct);
  }

  /**
   * Activities should implement this method to update the graphic content of its panel. The method
   * will be called from {@link module:AWT.Container#update} when needed.
   * @param {module:AWT.Rectangle} dirtyRegion - Specifies the area to be updated. When `null`,
   * it's the whole panel.
   */
  updateContent(dirtyRegion) {
    // To be overridden by subclasses. Here does nothing.
    return super.updateContent(dirtyRegion);
  }

  /**
   * Plays the specified event sound
   * @param {string} event - The type of event to be performed
   */
  playEvent(event) {
    this.act.eventSounds.play(event);
  }

  /**
   * Basic initialization procedure, common to all activities.
   */
  initActivity() {
    if (this.playing) {
      this.playing = false;
      this.ps.reportEndActivity(this.act, this.solved);
    }
    this.solved = false;
    this.ps.reportNewActivity(this.act, 0);
    this.attachEvents();
    this.enableCounters();
  }

  /**
   * Called when the activity starts playing
   */
  startActivity() {
    this.playing = true;
  }

  /**
   * Called by {@link module:JClicPlayer.JClicPlayer JClicPlayer} when this activity panel is fully visible, just after the
   * initialization process.
   */
  activityReady() {
    // To be overrided by subclasses
  }

  /**
   * Displays help about the activity
   */
  showHelp() {
    // To be overrided by subclasses
  }

  /**
   * Sets the real dimension of this ActivityPanel.
   * @param {module:AWT.Dimension} maxSize - The maximum surface available for the activity panel
   * @returns {module:AWT.Dimension}
   */
  setDimension(maxSize) {
    return new Dimension(
      Math.min(maxSize.width, this.act.windowSize.width),
      Math.min(maxSize.height, this.act.windowSize.height));
  }

  /**
   * Attaches the events specified in the `events` member to the `$div` member
   */
  attachEvents() {
    this.events.forEach(ev => this.attachEvent(this.$div, ev));
    // Prepare handler to check if we are in a touch device
    if (!settings.TOUCH_DEVICE && $.inArray(TOUCH_TEST_EVENT, this.events) === -1)
      this.attachEvent(this.$div, TOUCH_TEST_EVENT);
  }

  /**
   * Attaches a single event to the specified object
   * @param {external:jQuery} $obj - The object to which the event will be attached
   * @param {string} evt - The event name
   */
  attachEvent($obj, evt) {
    $obj.on(evt, this, event => {
      if (event.type === TOUCH_TEST_EVENT) {
        if (!settings.TOUCH_DEVICE)
          settings.TOUCH_DEVICE = true;
        if ($.inArray(TOUCH_TEST_EVENT, this.events) === -1) {
          // Disconnect handler
          $obj.off(TOUCH_TEST_EVENT);
          return;
        }
      }
      return event.data.processEvent.call(event.data, event);
    });
  }

  /**
   * Main handler used to process mouse, touch, keyboard and edit events.
   * @param {external:Event} event - The HTML event to be processed
   * @returns {boolean} - When this event handler returns `false`, jQuery will stop its
   * propagation through the DOM tree. See: {@link http://api.jquery.com/on}
   */
  processEvent(_event) {
    return false;
  }

  /**
   * Fits the panel within the `proposed` rectangle. The panel can occupy more space, but always
   * not surpassing the `bounds` rectangle.
   * @param {module:AWT.Rectangle} proposed - The proposed rectangle
   * @param {module:AWT.Rectangle} bounds - The maximum allowed bounds
   */
  fitTo(proposed, bounds) {
    const origin = new Point();
    if (this.act.absolutePositioned && this.act.absolutePosition !== null) {
      origin.x = Math.max(0, this.act.absolutePosition.x + proposed.pos.x);
      origin.y = Math.max(0, this.act.absolutePosition.y + proposed.pos.y);
      proposed.dim.width -= this.act.absolutePosition.x;
      proposed.dim.height -= this.act.absolutePosition.y;
    }
    const d = this.setDimension(new Dimension(
      Math.max(2 * this.act.margin + settings.MINIMUM_WIDTH, proposed.dim.width),
      Math.max(2 * this.act.margin + settings.MINIMUM_HEIGHT, proposed.dim.height)));
    if (!this.act.absolutePositioned) {
      origin.moveTo(
        Math.max(0, proposed.pos.x + (proposed.dim.width - d.width) / 2),
        Math.max(0, proposed.pos.y + (proposed.dim.height - d.height) / 2));
    }
    if (origin.x + d.width > bounds.dim.width)
      origin.x = Math.max(0, bounds.dim.width - d.width);
    if (origin.y + d.height > bounds.dim.height)
      origin.y = Math.max(0, bounds.dim.height - d.height);
    this.setBounds(new Rectangle(origin.x, origin.y, d.width, d.height));

    // Build accessible components at the end of current tree
    window.setTimeout(() => this.buildAccessibleComponents(), 0);
  }

  /**
   *
   * Builds the accessible components needed for this ActivityPanel
   * This method is called when all main elements are placed and visible, when the activity is ready
   * to start or when resized.
   */
  buildAccessibleComponents() {
    // Clear existing elements
    if (this.accessibleCanvas && this.$canvas && this.$canvas.children().length > 0) {
      // UPDATED May 2020: clearHitRegions has been deprecated!
      // this.$canvas.get(-1).getContext('2d').clearHitRegions();
      this.$canvas.empty();
    }
    // Create accessible elements in subclasses
  }

  /**
   *  Forces the ending of the activity.
   */
  forceFinishActivity() {
    // to be overrided by subclasses
  }

  /**
   * Ordinary ending of the activity, usually called form `processEvent`
   * @param {boolean} result - `true` if the activity was successfully completed, `false` otherwise
   */
  finishActivity(result) {
    this.playing = false;
    this.solved = result;

    if (this.bc !== null)
      this.bc.end();

    if (result) {
      this.setAndPlayMsg('final', 'finishedOk');
    } else {
      this.setAndPlayMsg('finalError', 'finishedError');
    }
    this.ps.activityFinished(this.solved);
    this.ps.reportEndActivity(this.act, this.solved);
  }

  /**
   * Sets the message to be displayed in the skin message box and optionally plays a sound event.
   * @param {string} msgCode - Type of message (initial, final, finalError...)
   * @param {string} [eventSoundsCode] - Optional name of the event sound to be played.
   */
  setAndPlayMsg(msgCode, eventSoundsCode) {
    const msg = this.act.messages[msgCode] || null;
    this.ps.setMsg(msg);
    if (msg === null || msg.mediaContent === null)
      this.playEvent(eventSoundsCode);
  }

  /**
   * Ends the activity
   */
  end() {
    this.forceFinishActivity();
    if (this.playing) {
      if (this.bc !== null)
        this.bc.end();
      this.ps.reportEndActivity(this.act, this.solved);
      this.playing = false;
      this.solved = false;
    }
    this.clear();
  }

  /**
   * Miscellaneous cleaning operations
   */
  clear() {
    // to be overridden by subclasses
  }

  /**
   * Enables or disables the three counters (time, score and actions)
   * @param {boolean} eTime - Whether to enable or disable the time counter
   * @param {boolean} eScore - Whether to enable or disable the score counter
   * @param {boolean} eActions - Whether to enable or disable the actions counter
   */
  enableCounters(eTime, eScore, eActions) {
    if (typeof eTime === 'undefined')
      eTime = this.act.bTimeCounter;
    if (typeof eScore === 'undefined')
      eScore = this.act.bScoreCounter;
    if (typeof eActions === 'undefined')
      eActions = this.act.bActionsCounter;

    this.ps.setCounterEnabled('time', eTime);
    if (this.act.countDownTime)
      this.ps.setCountDown('time', this.act.maxTime);
    this.ps.setCounterEnabled('score', eScore);
    this.ps.setCounterEnabled('actions', eActions);
    if (this.act.countDownActions)
      this.ps.setCountDown('actions', this.act.maxActions);
  }

  /**
   * Shuffles the contents of the activity
   * @param {module:boxes/ActiveBoxBag.ActiveBoxBag[]} bg - The sets of boxes to be shuffled
   * @param {boolean} visible - The shuffle process must be animated on the screen (not yet implemented!)
   * @param {boolean} fitInArea - Shuffled pieces cannot go out of the current area
   */
  shuffle(bg, visible, fitInArea) {
    const steps = this.act.shuffles;
    let i = steps;
    while (i > 0) {
      const k = i > steps ? steps : i;
      bg.forEach(abb => { if (abb) abb.shuffleCells(k, fitInArea); });
      i -= steps;
    }
  }
}

Object.assign(ActivityPanel.prototype, {
  /**
   * The Activity this panel is related to
   * @name module:Activity.ActivityPanel#act
   * @type {module:Activity.Activity} */
  act: null,
  /**
   * The jQuery div element used by this panel
   * @name module:Activity.ActivityPanel#$div
   * @type {external:jQuery} */
  $div: null,
  /**
   * The jQuery main canvas element used by this panel
   * @name module:Activity.ActivityPanel#$canvas
   * @type {external:jQuery} */
  $canvas: null,
  /**
   * Always true, since canvas hit regions have been deprecated!
   * See: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Hit_regions_and_accessibility
   * @name module:Activity.ActivityPanel#accessibleCanvas
   * @type {boolean}
   */
  accessibleCanvas: true,
  /**
   * The realized current {@link module:skins/Skin.Skin Skin}
   * @name module:Activity.ActivityPanel#skin
   * @type {module:skins/Skin.Skin} */
  skin: null,
  /**
   * Background element (currently a `span`) used to place animated GIFs when needed
   * @name module:Activity.ActivityPanel#$animatedBg
   * @type {external:jQuery} */
  $animatedBg: null,
  /**
   * Additional background element for animated GIFs, used in associations
   * @name module:Activity.ActivityPanel#$animatedBgB
   * @type {external:jQuery} */
  $animatedBgB: null,
  /**
   * `true` when the activity is solved, `false` otherwise
   * @name module:Activity.ActivityPanel#solved
   * @type {boolean} */
  solved: false,
  /**
   * The realized image used as a background
   * @name module:Activity.ActivityPanel#bgImage
   * @type {external:HTMLImageElement} */
  bgImage: null,
  /**
   * `true` while the activity is playing
   * @name module:Activity.ActivityPanel#playing
   * @type {boolean} */
  playing: false,
  /**
   * `true` if the activity is running for first time (not due to a click on the `replay` button)
   * @name module:Activity.ActivityPanel#firstRun
   * @type {boolean} */
  firstRun: true,
  /**
   * Currently selected item. Used in some types of activities.
   * @name module:Activity.ActivityPanel#currentItem
   * @type {number} */
  currentItem: 0,
  /**
   * The object used to connect cells and other elements in some types of activity
   * @name module:Activity.ActivityPanel#bc
   * @type {module:boxes/BoxConnector.BoxConnector} */
  bc: null,
  /**
   * The PlayStation used to realize media objects and communicate with the player services
   * (usually a {@link module:JClicPlayer.JClicPlayer JClicPlayer}
   * @name module:Activity.ActivityPanel#ps
   * @type {module:JClicPlayer.JClicPlayer} */
  ps: null,
  /**
   * The minimum size of this kind of ActivityPanel
   * @name module:Activity.ActivityPanel#minimumSize
   * @type {module:AWT.Dimension} */
  minimumSize: null,
  /**
   * The preferred size of this kind of ActivityPanel
   * @name module:Activity.ActivityPanel#preferredSize
   * @type {module:AWT.Dimension} */
  preferredSize: null,
  /**
   * List of events intercepted by this ActivityPanel. Current events are: 'keydown', 'keyup',
   * 'keypress', 'mousedown', 'mouseup', 'click', 'dblclick', 'mousemove', 'mouseenter',
   * 'mouseleave', 'mouseover', 'mouseout', 'touchstart', 'touchend', 'touchmove' and 'touchcancel'.
   * @name module:Activity.ActivityPanel#events
   * @type {string[]} */
  events: ['click'],
  backgroundColor: null,
  backgroundTransparent: false,
  border: null,
});

/**
 * The panel class associated to each type of activity
 * @type {module:Activity.ActivityPanel} */
Activity.Panel = ActivityPanel;

export default Activity;