/**
* File : skins/CustomSkin.js
* Created : 12/02/2018
* 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 Skin from './Skin.js';
import Counter from './Counter.js';
import { getMsg, checkColor, getImgClipUrl } from '../Utils.js';
import { Rectangle } from '../AWT.js';
import ActiveBox from '../boxes/ActiveBox.js';
/**
* Custom {@link module:skins/Skin.Skin Skin} for JClic.js, built assembling specific cuts of a canvas (usually a PNG file) defined in an XML file
* @extends module:skins/Skin.Skin
*/
export class CustomSkin extends Skin {
/**
* CustomSkin 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 class name
* @param {object} [options] - Optional parameter with additional options
*/
constructor(ps, name = null, options = null) {
// CustomSkin extends [Skin](Skin.html)
super(ps, name, options);
//console.log(this.options)
this.$mainPanel = $('<div/>', { class: 'JClicCustomMainPanel' });
this.$gridPanel = $('<div/>', { class: 'JClicGridPanel' });
for (let i = 0; i < 9; i++)
this.$gridPanel.append($('<div/>', { class: `JClicCell JClicCell${i + 1}` }));
this.$mainPanel.append(this.$gridPanel);
this.$playerCnt.detach().addClass('JClicPlayerCell').appendTo(this.$mainPanel);
this.$div.prepend(this.$mainPanel);
// Add buttons
if (options.buttons) {
Object.keys(options.buttons.button).forEach(k => {
const k2 = k === 'about' ? 'reports' : k;
const msg = getMsg(this.msgKeys[k2] || k2);
this.buttons[k2] = $('<button/>', { class: `JClicBtn JClicTransform Btn-${k2}`, title: msg, 'aria-label': msg, disabled: typeof this.msgKeys[k2] === 'undefined' })
.on('click', evt => { if (ps.actions[k2]) ps.actions[k2].processEvent(evt); });
this.$mainPanel.append(this.buttons[k2]);
});
}
// Add message box
if (options.rectangle.messages) {
this.msgBox = new ActiveBox();
this.msgBox.role = 'message';
this.$msgBoxDiv = $('<div/>', { class: 'JClicMsgBox' })
.on('click', () => {
this.msgBox.playMedia(ps);
return false;
});
this.$mainPanel.append(this.$msgBoxDiv);
}
// Add counters
if (false !== this.ps.options.counters && options.counters && options.counters.counter) {
$.each(Skin.prototype.counters, (name, _val) => {
if (options.counters.counter[name]) {
const msg = getMsg(name);
this.counters[name] = new Counter(name, $('<div/>', { class: `JClicCounter JClicTransform Counter-${name}`, title: msg, 'aria-label': msg })
.html('000')
.appendTo(this.$mainPanel));
}
});
}
// Add progress animation
if (options.progressAnimation) {
this.$progressAnimation = $('<div/>', { class: 'JClicProgressAnimation JClicTransform' });
this.$mainPanel.append(this.$progressAnimation);
}
}
/**
* 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.$mainPanel.find('.JClicBtn').attr('tabindex', status ? '0' : '-1');
}
/**
* Computes the CSS styles used by this skin in thre moodes: main, half ant twoThirds.
* The resulting strings will be stored in `cssVariants`
* @returns {string}
*/
_computeStyleSheets() {
const
maxw = this.options.dimension.preferredSize.width,
maxh = this.options.dimension.preferredSize.height;
this.twoThirdsMedia = { width: maxw, height: maxh };
this.halfMedia = {
width: Math.round(2 * maxw / 3),
height: Math.round(2 * maxh / 3)
};
// Panels:
const
ph0 = this.options.rectangle.frame.left,
ph1 = ph0 + this.options.rectangle.player.left,
ph2 = ph0 + this.options.slicer.left,
ph3 = ph0 + this.options.slicer.right,
ph4 = ph1 + this.options.rectangle.player.width,
ph5 = ph0 + this.options.rectangle.frame.width,
pv0 = this.options.rectangle.frame.top,
pv1 = pv0 + this.options.rectangle.player.top,
pv2 = pv0 + this.options.slicer.top,
pv3 = pv0 + this.options.slicer.bottom,
pv4 = pv1 + this.options.rectangle.player.height,
pv5 = pv0 + this.options.rectangle.frame.height,
imgElement = this.ps.project.mediaBag.getElement(this.options.image, true),
imgUrl = imgElement.data && imgElement.data.src ? imgElement.data.src : '',
box1 = imgElement.data ? getImgClipUrl(imgElement.data, new Rectangle(ph0, pv0, ph2 - ph0, pv2 - pv0)) : '',
box2 = imgElement.data ? getImgClipUrl(imgElement.data, new Rectangle(ph2 - ph0, pv0, ph3 - ph2, pv2 - pv0)) : '',
box3 = imgElement.data ? getImgClipUrl(imgElement.data, new Rectangle(ph3, pv0, ph5 - ph3, pv2 - pv0)) : '',
box4 = imgElement.data ? getImgClipUrl(imgElement.data, new Rectangle(ph0, pv2 - pv0, ph2 - ph0, pv3 - pv2)) : '',
box6 = imgElement.data ? getImgClipUrl(imgElement.data, new Rectangle(ph3 - ph0, pv2 - pv0, ph5 - ph3, pv3 - pv2)) : '',
box7 = imgElement.data ? getImgClipUrl(imgElement.data, new Rectangle(ph0, pv3 - pv0, ph2 - ph0, pv5 - pv3)) : '',
box8 = imgElement.data ? getImgClipUrl(imgElement.data, new Rectangle(ph2 - ph0, pv3 - pv0, ph3 - ph2, pv5 - pv3)) : '',
box9 = imgElement.data ? getImgClipUrl(imgElement.data, new Rectangle(ph3, pv3 - pv0, ph5 - ph3, pv5 - pv3)) : '';
let css = `
.ID .JClicCustomMainPanel {flex-grow:1;position:relative;background-color: ${checkColor(this.options.color.fill.value)};}
.ID .JClicGridPanel {position:absolute;width:100%;height:100%;display:grid;grid-template-columns:${ph2 - ph0}px 1fr ${ph5 - ph3}px;grid-template-rows:${pv2 - pv0}px 1fr ${pv5 - pv3}px;}
.ID .JClicCell {background-repeat:no-repeat;background-size:contain;}
.ID .JClicPlayerCell {position:absolute;top:${pv1 - pv0}px;right:${ph5 - ph4}px;bottom:${pv5 - pv4}px;left:${ph1 - ph0}px;}
.ID .JClicCell1 {background-image:url(${box1});}
.ID .JClicCell2 {background-image:url(${box2});background-repeat:repeat-x;}
.ID .JClicCell3 {background-image:url(${box3});}
.ID .JClicCell4 {background-image:url(${box4});background-repeat:repeat-y;}
.ID .JClicCell5 {}
.ID .JClicCell6 {background-image:url(${box6});background-repeat:repeat-y;}
.ID .JClicCell7 {background-image:url(${box7});}
.ID .JClicCell8 {background-image:url(${box8});background-repeat:repeat-x;}
.ID .JClicCell9 {background-image:url(${box9});}`;
let cssHalf = `
.ID .JClicGridPanel {grid-template-columns:${Math.round((ph2 - ph0) / 2)}px 1fr ${Math.round((ph5 - ph3) / 2)}px;grid-template-rows:${Math.round((pv2 - pv0) / 2)}px 1fr ${Math.round((pv5 - pv3) / 2)}px;}
.ID .JClicPlayerCell {top:${Math.round((pv1 - pv0) / 2)}px;right:${Math.round((ph5 - ph4) / 2)}px;bottom:${Math.round((pv5 - pv4) / 2)}px;left:${Math.round((ph1 - ph0) / 2)}px;}
.ID .JClicTransform {transform: scale(0.5);}`;
let cssTwoThirds = `
.ID .JClicGridPanel {grid-template-columns:${Math.round(2 * (ph2 - ph0) / 3)}px 1fr ${Math.round(2 * (ph5 - ph3) / 3)}px;grid-template-rows:${Math.round(2 * (pv2 - pv0) / 3)}px 1fr ${Math.round(2 * (pv5 - pv3) / 3)}px;}
.ID .JClicPlayerCell {top:${Math.round(2 * (pv1 - pv0) / 3)}px;right:${Math.round(2 * (ph5 - ph4) / 3)}px;bottom:${Math.round(2 * (pv5 - pv4) / 3)}px;left:${Math.round(2 * (ph1 - ph0) / 3)}px;}
.ID .JClicTransform {transform: scale(0.666);}`;
// Buttons:
if (this.options.buttons) {
const bt = this.options.buttons;
let wBase = 30, hBase = 30, offsetBase = {};
if (bt.settings) {
if (bt.settings.dimension) {
wBase = bt.settings.dimension.width || wBase;
hBase = bt.settings.dimension.height || hBase;
}
if (bt.settings.offset)
Object.assign(offsetBase, bt.settings.offset);
}
Object.keys(this.options.buttons.button).forEach(k => {
const
btn = bt.button[k],
k2 = k === 'about' ? 'reports' : k;
let w = wBase, h = hBase, offset = offsetBase;
if (btn.settings) {
if (btn.settings.dimension) {
w = btn.settings.dimension.width || w;
h = btn.settings.dimension.height || h;
}
if (btn.settings.offset)
offset = Object.assign({}, offsetBase, btn.settings.offset);
}
const
x = btn.point.pos.left,
xp = x < ph2 ? `left:${x}` : `right:${ph5 - x - w}`,
xpHalf = x < ph2 ? `left:${Math.round(x / 2 - w / 4)}` : `right:${Math.round((ph5 - x - w) / 2 - w / 4)}`,
xpTwoThirds = x < ph2 ? `left:${Math.round(2 * x / 3 - w / 6)}` : `right:${Math.round(2 * (ph5 - x - w) / 3 - w / 6)}`,
y = btn.point.pos.top,
yp = y < pv2 ? `top:${y}` : `bottom:${pv5 - y - h}`,
ypHalf = y < pv2 ? `top:${Math.round(y / 2 - h / 4)}` : `bottom:${Math.round((pv5 - y - h) / 2 - h / 4)}`,
ypTwoThirds = y < pv2 ? `top:${Math.round(2 * y / 3 - h / 6)}` : `bottom:${Math.round(2 * (pv5 - y - h) / 3 - h / 6)}`,
xs = btn.point.source.left,
ys = btn.point.source.top;
css += `.ID .Btn-${k2} {position:absolute;${xp}px;${yp}px;width:${w}px;height:${h}px;background:url(${imgUrl}) !important;background-position:-${xs}px -${ys}px !important;}\n`;
cssHalf += `.ID .Btn-${k2} {${xpHalf}px;${ypHalf}px;}\n`;
cssTwoThirds += `.ID .Btn-${k2} {${xpTwoThirds}px;${ypTwoThirds}px;}\n`;
if (offset.active)
css += `.ID .Btn-${k2}:active {background-position:-${xs + offset.active.right}px -${ys + offset.active.down}px !important;}\n`;
if (offset.over)
css += `.ID .Btn-${k2}:hover {background-position:-${xs + offset.over.right}px -${ys + offset.over.down}px !important;}\n`;
if (offset.disabled)
css += `.ID .Btn-${k2}:disabled {background-position:-${xs + offset.disabled.right}px -${ys + offset.disabled.down}px !important;}\n`;
});
}
// Counters:
if (this.options.counters && this.options.counters.settings) {
const cnt = this.options.counters;
let wBase = 35, hBase = 20;
if (cnt.settings.dimension && cnt.settings.dimension.counter) {
wBase = (cnt.settings.dimension.counter.width || wBase);
hBase = cnt.settings.dimension.counter.height || hBase;
}
let wLb = 37, hLb = 14;
if (cnt.settings.dimension && cnt.settings.dimension.label) {
wLb = (cnt.settings.dimension.label.width || wLb);
hLb = cnt.settings.dimension.label.height || hLb;
}
let bColor = 'black';
if (cnt.style && cnt.style.color && cnt.style.color.foreground)
bColor = checkColor(cnt.style.color.foreground.value || bColor);
let lbFntSize = hLb - 4;
let lbFntFamily = 'Roboto';
if (cnt.style && cnt.style.font && cnt.style.font.label) {
lbFntSize = Math.max(8, cnt.style.font.label.size || lbFntSize);
lbFntFamily = `${cnt.style.font.label.family || 'Roboto'},Roboto,sans-serif`;
}
css += `.ID .JClicCounter {font-size:${hBase - 2}px;color:${bColor}}\n`;
Object.keys(this.options.counters.counter).forEach(k => {
const
counter = cnt.counter[k];
let w = wBase, h = hBase;
const
x = counter.point.counter.left,
xl = counter.point.label.left || (x - Math.round((wLb - wBase) / 2)),
xp = x < ph2 ? `left:${x}` : `right:${ph5 - x - w}`,
xpHalf = x < ph2 ? `left:${Math.round(x / 2 - w / 4)}` : `right:${Math.round((ph5 - x - w) / 2 - w / 4)}`,
xpTwoThirds = x < ph2 ? `left:${Math.round(2 * x / 3 - w / 6)}` : `right:${Math.round(2 * (ph5 - x - w) / 3 - w / 6)}`,
y = counter.point.counter.top,
yl = counter.point.label.top || (y - hLb),
yp = y < pv2 ? `top:${y}` : `bottom:${pv5 - y - h}`,
ypHalf = y < pv2 ? `top:${Math.round(y / 2 - h / 4)}` : `bottom:${Math.round((pv5 - y - h) / 2 - h / 4)}`,
ypTwoThirds = y < pv2 ? `top:${Math.round(2 * y / 3 - h / 6)}` : `bottom:${Math.round(2 * (pv5 - y - h) / 3 - h / 6)}`;
// counter:
css += `.ID .Counter-${k} {position:absolute;${xp}px;${yp}px;width:${w}px;height:${h}px;line-height:${h}px;}\n`;
// label:
css += `.ID .Counter-${k}:before {content:"${getMsg(k)}";font-size:${lbFntSize}px;font-family:${lbFntFamily};width:${wLb}px;height:${hLb}px;line-height:${hLb}px;position:absolute;top:${yl - y}px;left:${xl - x}px;}`;
// reduced sizes:
cssHalf += `.ID .Counter-${k} {${xpHalf}px;${ypHalf}px;}\n`;
cssTwoThirds += `.ID .Counter-${k} {${xpTwoThirds}px;${ypTwoThirds}px;}\n`;
});
}
// Progress animation:
if (this.options.progressAnimation) {
const pa = this.options.progressAnimation;
let w = 30, h = 30;
if (pa.dimension) {
w = pa.dimension.width || w;
h = pa.dimension.height || h;
}
const
x = pa.point.pos.left,
xp = x < ph2 ? `left:${x}` : `right:${ph5 - x - w}`,
xpHalf = x < ph2 ? `left:${Math.round(x / 2 - w / 4)}` : `right:${Math.round((ph5 - x - w) / 2 - w / 4)}`,
xpTwoThirds = x < ph2 ? `left:${Math.round(2 * x / 3 - w / 6)}` : `right:${Math.round(2 * (ph5 - x - w) / 3 - w / 6)}`,
y = pa.point.pos.top,
yp = y < pv2 ? `top:${y}` : `bottom:${pv5 - y - h}`,
ypHalf = y < pv2 ? `top:${Math.round(y / 2 - h / 4)}` : `bottom:${Math.round((pv5 - y - h) / 2 - h / 4)}`,
ypTwoThirds = y < pv2 ? `top:${Math.round(2 * y / 3 - h / 6)}` : `bottom:${Math.round(2 * (pv5 - y - h) / 3 - h / 6)}`,
xs = pa.point.source.left,
ys = pa.point.source.top;
css += `.ID .JClicProgressAnimation {position:absolute;${xp}px;${yp}px;width:${w}px;height:${h}px;background:url(${imgUrl});background-position:-${xs}px -${ys}px;}\n`;
cssHalf += `.ID .JClicProgressAnimation {${xpHalf}px;${ypHalf}px;}\n`;
cssTwoThirds += `.ID .JClicProgressAnimation {${xpTwoThirds}px;${ypTwoThirds}px;}\n`;
if (pa.frames && pa.direction) {
const
dx = (pa.step || w) * (pa.direction === 'right' ? 1 : pa.direction === 'left' ? -1 : 0),
dy = (pa.step || h) * (pa.direction === 'down' ? 1 : pa.direction === 'up' ? -1 : 0);
css += `\n@keyframes anim {100% {background-position:${(xs + dx * pa.frames) * -1}px ${(ys + dy * pa.frames) * -1}px;}}\n.ID .JClicProgressAnimation {animation: anim ${pa.frames * pa.delay}ms steps(${pa.frames}) infinite;}`;
}
}
// Messages box:
if (this.options.rectangle.messages) {
const
bx = this.options.rectangle.messages,
left = ph0 + bx.left,
right = ph5 - bx.width - bx.left - ph0,
tb = bx.top < pv2 ? `top:${bx.top}` : `bottom:${pv5 - bx.height - bx.top}`,
tbHalf = bx.top < pv2 ? `top:${Math.round(bx.top / 2)}` : `bottom:${Math.round((pv5 - bx.height - bx.top) / 2)}`,
tbTwoThirds = bx.top < pv2 ? `top:${Math.round(2 * bx.top / 3)}` : `bottom:${Math.round(2 * (pv5 - bx.height - bx.top) / 3)}`;
css += `.ID .JClicMsgBox {position:absolute;left:${left}px;right:${right}px;height:${bx.height}px;${tb}px;}`;
cssHalf += `.ID .JClicMsgBox {left:${Math.round(left / 2)}px;right:${Math.round(right / 2)}px;height:${Math.round(bx.height / 2)}px;${tbHalf}px;}`;
cssTwoThirds += `.ID .JClicMsgBox {left:${Math.round(2 * left / 3)}px;right:${Math.round(2 * right / 3)}px;height:${Math.round(2 * bx.height / 3)}px;${tbTwoThirds}px;}`;
}
// TODO: Implement status messages?
// Store results in `cssVariants`
this.cssVariants = {
default: this.mainCSS + css,
half: cssHalf,
twoThirds: cssTwoThirds
};
}
/**
* 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'
* @override
* @returns {string}
*/
_getStyleSheets(media = 'default') {
if (!this.cssVariants)
this._computeStyleSheets();
return `${super._getStyleSheets(media)}${this.cssVariants[media] || ''}`;
}
/**
* Sets/unsets the 'wait' state
* @override
* @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) {
super.setWaitCursor(status);
if (this.$progressAnimation)
this.$progressAnimation.css('animation-play-state', this.waitCursorCount > 0 ? 'running' : 'paused');
}
}
Object.assign(CustomSkin.prototype, {
/**
* Class name of this skin. It will be used as a base selector in the definition of all CSS styles.
* @name module:skins/CustomSkin.CustomSkin#skinId
* @override
* @type {string} */
skinId: 'JClicCustomSkin',
/**
* The name of the image file to be used as a base of this skin.
* @name module:skins/CustomSkin.CustomSkin#image
* @type {string} */
image: null,
/**
* Styles used in this skin
* @name module:skins/CustomSkin.CustomSkin#skinCSS
* @override
* @type {string} */
mainCSS: '\
.ID .JClicPlayerCnt {margin:0;}\
.ID .JClicBtn:focus {outline:0;}\
.ID .JClicCounter {font-family:Roboto,sans-serif;text-align:center;}',
/**
* Specifc styles (`default`, `half` and `twoThirds`) computed at run-time,
* based on the provided XML file
* @name module:skins/CustomSkin.CustomSkin#cssVariants
* @type {object} */
cssVariants: null,
/**
* Key ids of currently supported buttons, associated with its helper literal
* @name module:skins/CustomSkin.CustomSkin#msgKeys
* @type {object} */
msgKeys: {
next: 'Next activity',
prev: 'Previous activity',
info: 'Information',
help: 'Help',
reports: 'Reports',
// TODO: Implement audio on/off!
audio: 'Audio on/off',
reset: 'Reset activity',
},
/**
* Graphic indicator of loading progress
* @name module:skins/CustomSkin.Skin#$progressAnimation
* @type {external:jQuery} */
$progressAnimation: null,
});
// Register this class in the list of available skins
export default Skin.registerClass('custom', CustomSkin);