boxes_ActiveBagContent.js

/**
 *  File    : boxes/ActiveBagContent.js
 *  Created : 13/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
 */

import $ from 'jquery';
import BoxBase from './BoxBase.js';
import ActiveBoxContent from './ActiveBoxContent.js';
import Shaper from '../shapers/Shaper.js';
import { Rectangle } from '../AWT.js';
import { settings, attrForEach, nSlash, getBoolean, getAttr, setAttr } from '../Utils.js';

/**
 * This class packs a collection of {@link module:boxes/ActiveBoxContent.ActiveBoxContent ActiveBoxContent} objects and provides methods to access
 * and manage it. The two main members of `ActiveBagContent` are the {@link module:shapers/Shaper.Shaper Shaper}, responsible for
 * determining the position and shape of each {@link module:boxes/ActiveBox.ActiveBox ActiveBox}, and the {@link module:boxes/BoxBase.BoxBase BoxBase} (field `style`),
 * provider of a common visual style.
 */
export class ActiveBagContent {
  /**
   * ActiveBagContent constructor
   * @param {string} [id] - An optional text tag identifying this ActiveBagContent
   * @param {number} ncw - In grid-based distributions, number of columns.
   * @param {number} nch - In grid-based distributions, number of rows.
   */
  constructor(id, ncw, nch) {
    if (id)
      this.id = id;
    this.cells = [];
    this.ncw = Math.max(1, ncw);
    this.nch = Math.max(1, nch);
  }

  /**
   * Loads the object settings from a specific JQuery XML element
   * @param {external:jQuery} $xml - The XML element to parse
   * @param {module:bags/MediaBag.MediaBag} mediaBag - The project's MediaBag
   */
  setProperties($xml, mediaBag) {
    let bug = false;
    attrForEach($xml.get(0).attributes, (name, val) => {
      switch (name) {
        case 'id':
          this.id = val;
          break;
        case 'image':
          this.image = nSlash(val);
          break;
        // Bug in JClic beta 1: "columns" is number of rows, and "rows" is number of columns.
        // Was corrected in beta 2: If "cols" is specified, "rows" are rows and "cols" are columns.
        case 'rows':
          this.nch = Number(val);
          break;
        case 'columns':
          bug = true;
        /* falls through */
        case 'cols':
          this.ncw = Number(val);
          break;
        case 'cellWidth':
          this.w = Number(val);
          break;
        case 'cellHeight':
          this.h = Number(val);
          break;
        case 'border':
          this.border = getBoolean(val);
          break;
      }
    });

    if (bug) {
      let n = this.ncw;
      this.ncw = this.nch;
      this.nch = n;
    }

    $xml.children().each((_n, child) => {
      const $node = $(child);
      switch (child.nodeName) {
        case 'style':
          this.style = new BoxBase(null).setProperties($node);
          break;
        case 'shaper':
          const shaperClassName = $node.attr('class'),
            nCols = Math.max(1, $node.attr('cols')),
            nRows = Math.max(1, $node.attr('rows'));
          this.shaper = Shaper.getShaper(shaperClassName, nCols, nRows);
          this.shaper.setProperties($node);
          break;
        case 'ids':
          // Used in special cases where all cells have empty content with only 'ids'
          this.ids = child.textContent;
          this.ids.split(' ').forEach((id, i) => { this.cells[i] = new ActiveBoxContent(Number(id)); });
          break;
        case 'cell':
          this.cells.push(new ActiveBoxContent().setProperties($node, mediaBag));
          break;
      }
    });

    let n = this.cells.length;

    // Create cells when `cells` is empty
    if (n === 0 && this.shaper && this.shaper.nCells > 0) {
      this.initiallyEmptyCells = true;
      n = this.shaper.nCells;
      this.getActiveBoxContent(n - 1);
    }

    // Assign ids when cells have empty content (they are just shapes)
    if (n > 0) {
      let empty = true;
      for (let i = 0; i < n; i++) {
        const bxc = this.getActiveBoxContent(i);
        if (bxc.id !== -1 || bxc.item !== -1 || !bxc.isEmpty()) {
          empty = false;
          break;
        }
      }
      if (empty) {
        for (let i = 0; i < n; i++)
          this.getActiveBoxContent(i).id = i;
      }
    }

    // Link [BoxBase](BoxBase.html) objects of `cells` elements to `style`
    if (this.style)
      this.cells.forEach((abc) => { if (abc.style) abc.style.parent = this.style; });

    return this;
  }

  /**
   * 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() {
    const fields = [
      'id', 'image',
      'ncw', 'nch',
      'w', 'h', 'border',
      'style', // BoxBase
      'shaper', // Shaper
    ];
    if (!this.initiallyEmptyCells)
      fields.push(this.ids ? 'ids' : 'cells'); // ActiveBoxContent
    return getAttr(this, fields);
  }

  /**
   * Reads the properties of this ActiveBagContent from a data object
   * @param {object} data - The data object to be parsed
   * @param {module:bags/MediaBag.MediaBag} mediaBag - The project's MediaBag
   * @returns {module:boxes/ActiveBagContent.ActiveBagContent}
   */
  setAttributes(data, mediaBag) {
    setAttr(this, data, [
      'id', 'image',
      'ncw', 'nch',
      'w', 'h', 'border',
      { key: 'style', fn: BoxBase },
      { key: 'shaper', fn: Shaper },
      'ids',
      { key: 'cells', fn: ActiveBoxContent, group: 'array', params: [mediaBag] },
    ]);

    let n = this.cells.length;

    // Create cells when `cells` is empty
    if (n === 0 && this.shaper && this.shaper.nCells > 0) {
      this.initiallyEmptyCells = true;
      n = this.shaper.nCells;
      this.getActiveBoxContent(n - 1);
      if (this.ids)
        this.ids.split(' ').forEach((id, i) => { this.getActiveBoxContent(i).id = Number(id); });
    }

    // Assign ids when cells have empty content (they are just shapes)
    if (n > 0) {
      let empty = true;
      for (let i = 0; i < n; i++) {
        const bxc = this.getActiveBoxContent(i);
        if (bxc.id !== -1 || bxc.item !== -1 || !bxc.isEmpty()) {
          empty = false;
          break;
        }
      }
      if (empty) {
        for (let i = 0; i < n; i++)
          this.getActiveBoxContent(i).id = i;
      }
    }

    // Link [BoxBase](BoxBase.html) objects of `cells` elements to `style`
    if (this.style)
      this.cells.forEach(abc => { if (abc.style) abc.style.parent = this.style; });

    if (mediaBag)
      this.cells.forEach(abc => abc.realizeContent(mediaBag));

    return this;
  }

  /**
   * Prepares the media content of all elements
   * @param {module:JClicPlayer.JClicPlayer} playStation - The {@link module:JClicPlayer.JClicPlayer JClicPlayer}
   */
  prepareMedia(playStation) {
    this.cells.forEach(abc => abc.prepareMedia(playStation));
  }

  /**
   * Gets the estimated total width of this content bag
   * @returns {number}
   */
  getTotalWidth() {
    return this.w * this.ncw;
  }

  /**
   * Gets the estimated total height of this bag
   * @returns {number}
   */
  getTotalHeight() {
    return this.h * this.nch;
  }

  /**
   * Gets the total number of cells of this bag
   * @returns {number}
   */
  getNumCells() {
    return this.cells.length;
  }

  /**
   * Checks if the bag is empty
   * @returns {boolean}
   */
  isEmpty() {
    return this.cells.length === 0;
  }

  /**
   * Retrieves the {@link module:shapers/Shaper.Shaper Shaper} of this bag, creating a new one if it was _null_
   * @returns {module:shapers/Shaper.Shaper}
   */
  getShaper() {
    if (this.shaper === null)
      this.shaper = Shaper.getShaper('@Rectangular', this.ncw, this.nch);
    return this.shaper;
  }

  /**
   * Retrieves the {@link module:boxes/BoxBase.BoxBase BoxBase} of this bag, creating a new one if it was _null_
   * @returns {module:boxes/BoxBase.BoxBase}
   */
  getBoxBase() {
    if (this.style === null)
      this.style = new BoxBase();
    return this.style;
  }

  /**
   * Adds a new {@link module:boxes/ActiveBoxContent.ActiveBoxContent ActiveBoxContent} to this bag
   * @param {module:boxes/ActiveBoxContent.ActiveBoxContent} ab - The ActiveBoxContent to add
   */
  addActiveBoxContent(ab) {
    this.cells.push(ab);
    if (this.ncw === 0 || this.nch === 0) {
      this.ncw = this.nch = 1;
    }
  }

  /**
   * Gets the nth {@link module:boxes/ActiveBoxContent.ActiveBoxContent ActiveBoxContent} in `cells`
   * @param {number} i - The index of the content to be retrieved
   * @returns {module:boxes/ActiveBoxContent.ActiveBoxContent}
   */
  getActiveBoxContent(i) {
    if (i >= this.cells.length) {
      for (let j = this.cells.length; j <= i; j++)
        this.cells.push(new ActiveBoxContent());
    }
    return this.cells[i];
  }

  /**
   * Finds the ActiveBoxContent with specific `id` and `item` values
   * @param {number} id
   * @param {number} item
   * @returns {module:boxes/ActiveBoxContent.ActiveBoxContent}
   */
  getActiveBoxContentWith(id, item) {
    return this.cells.find(bxc => bxc.id === id && bxc.item === item);
  }

  /**
   * Sets the content of the cells based on a image spliced by a shaper
   * @param {module:bags/MediaBag.MediaBag} mb - The MediaBag used to retrieve the image
   * @param {module:shapers/Shaper.Shaper} sh - The Shaper used to splice the image
   * @param {boolean} roundSizes - When `true`, the size and coordinates of cells will be rounded
   * to the nearest integer values.
   */
  setImgContent(mb, sh, roundSizes) {
    if (sh)
      this.setShaper(sh);

    if (this.shaper.className === '@Holes')
      this.shaper.hasRemainder = true;

    this.ncw = this.shaper.nCols;
    this.nch = this.shaper.nRows;
    const mbe = mb.elements[this.image];
    if (mb && this.image && mbe && mbe.ready) {
      this.img = mbe.data;
      if (mbe.animated)
        this.animatedGifFile = mbe.getFullPath();
      this.w = this.img.width / this.ncw;
      this.h = this.img.height / this.nch;
      if (roundSizes) {
        this.w = Math.round(this.w);
        this.h = Math.round(this.h);
      }
    } else {
      this.img = null;
      this.w = Math.max(this.w, 10);
      this.h = Math.max(this.h, 10);
    }

    const r = new Rectangle(0, 0, this.w * this.ncw, this.h * this.nch);
    for (let i = 0; i < this.shaper.nCells; i++)
      this.getActiveBoxContent(i).setImgContent(this.img, this.shaper.getShape(i, r), this.animatedGifFile);

    if (this.shaper.hasRemainder) {
      this.backgroundContent = new ActiveBoxContent();
      this.backgroundContent.setImgContent(this.img, this.shaper.getRemainderShape(r));
    }
  }

  /**
   * Sets the content of this bag based on an array of strings
   * @param {string[]} txt - The array of strings to be used as content.
   * @param {number} setNcw - Number of columns
   * @param {number} setNch - Number of rows
   */
  setTextContent(txt, setNcw, setNch) {
    this.ncw = Math.max(1, setNcw);
    this.nch = Math.max(1, setNch);
    const n = this.ncw * this.nch;
    for (let i = 0; i < n; i++)
      this.getActiveBoxContent(i).setTextContent(i >= txt.length || txt[i] === null ? '' : txt[i]);
  }

  /**
   * Sets `id` values to a all the {@link module:boxes/ActiveBoxContent.ActiveBoxContent ActiveBoxContent} elements of his bag.
   * @param {number[]} ids -Array of numeric identifiers
   */
  setIds(ids) {
    for (let i = 0; i < ids.length && i < this.cells.length; i++)
      this.getActiveBoxContent(i).id = ids[i];
  }

  /**
   * Sets `value` to the `key` attribute of all cells
   * @param {string} key - The key where the value will be stored
   * @param {any} value - The supplied value. Can be of any type.
   */
  setCellsAttribute(key, value) {
    this.cells.forEach(abc => abc[key] = value);
  }

  /**
   *
   * Cheks if the `id` values of all {@link module:boxes/ActiveBoxContent.ActiveBoxContent ActiveBoxContent} objects are -1 and, if true,
   * sets new ids to them, with values between 0 and `maxId`
   * @param {number} maxId - The maximum value of identifiers
   */
  avoidAllIdsNull(maxId) {
    if (this.cells.every(abc => abc.id === -1)) {
      maxId = Math.max(1, maxId);
      this.cells.forEach((abc, n) => { abc.id = n % maxId; });
    }
  }
}

Object.assign(ActiveBagContent.prototype, {
  /**
   * The global identifier of this object: `primary`, `secondary`...
   * @name module:boxes/ActiveBagContent.ActiveBagContent#id
   * @type {string} */
  id: 'primary',
  /**
   * The name of the image file used as a common image of this bag
   * @name module:boxes/ActiveBagContent.ActiveBagContent#image
   * @type {string} */
  image: null,
  /**
   * The built image object
   * @name module:boxes/ActiveBagContent.ActiveBagContent#img
   * @type {external:HTMLImageElement} */
  img: null,
  /**
   * Name of the img source when is an animated GIF
   * @name module:boxes/ActiveBagContent.ActiveBagContent#animatedGifFile
   * @type {string} */
  animatedGifFile: null,
  /**
   * Number of columns when cells are distributed in a grid
   * @name module:boxes/ActiveBagContent.ActiveBagContent#ncw
   * @type {number} */
  ncw: 1,
  /**
   * Number of rows when cells are distributed in a grid
   * @name module:boxes/ActiveBagContent.ActiveBagContent#nch
   * @type {number} */
  nch: 1,
  /**
   * Optimal cell width
   * @name module:boxes/ActiveBagContent.ActiveBagContent#w
   * @type {number} */
  w: settings.DEFAULT_GRID_ELEMENT_SIZE,
  /**
   * Optimal cell height
   * @name module:boxes/ActiveBagContent.ActiveBagContent#h
   * @type {number} */
  h: settings.DEFAULT_GRID_ELEMENT_SIZE,
  /**
   * Whether the cells must have a border or not
   * @name module:boxes/ActiveBagContent.ActiveBagContent#border
   * @type {boolean} */
  border: true,
  /**
   * The BoxBase used for this bag of cell contents
   * @name module:boxes/ActiveBagContent.ActiveBagContent#style
   * @type {module:boxes/BoxBase.BoxBase} */
  style: null,
  /**
   * The Shaper used to define the specific shape of each cell
   * @name module:boxes/ActiveBagContent.ActiveBagContent#shaper
   * @type {module:shapers/Shaper.Shaper} */
  shaper: null,
  /**
   * An optional ActiveBoxContent object with background settings.
   * @name module:boxes/ActiveBagContent.ActiveBagContent#backgroundContent
   * @type {module:boxes/ActiveBoxContent.ActiveBoxContent} */
  backgroundContent: null,
  /**
   * The main Array of {@link module:boxes/ActiveBoxContent.ActiveBoxContent ActiveBoxContent} objects
   * @name module:boxes/ActiveBagContent.ActiveBagContent#cells
   * @type {module:boxes/ActiveBoxContent.ActiveBoxContent[]} */
  cells: null,
  /**
   * The default value to be assigned at the 'id' field of children
   * @name module:boxes/ActiveBagContent.ActiveBagContent#defaultIdValue
   * @type {number} */
  defaultIdValue: -1,
  /**
   * Used in special cases where all cells have empty content with only numeric identifiers
   * @name module:boxes/ActiveBagContent.ActiveBagContent#ids
   * @type {string} */
  ids: null,
});

export default ActiveBagContent;