@oat-sa/tao-test-runner-qti
Version:
TAO Test Runner QTI implementation
447 lines (390 loc) • 14.8 kB
JavaScript
/**
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; under version 2
* of the License (non-upgradable).
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* Copyright (c) 2019 (original work) Open Assessment Technologies SA;
*/
/**
* Create Text to Speech component which can playback APIP media content
*
* @author Anton Tsymuk <anton@taotesting.com>
*/
import $ from 'jquery';
import __ from 'i18n';
import ttsTemplate from 'taoQtiTest/runner/plugins/tools/apipTextToSpeech/textToSpeech.tpl';
import component from 'ui/component';
import interact from 'interact';
import makeStackable from 'ui/component/stackable';
import makePlaceable from 'ui/component/placeable';
import feedback from 'ui/feedback';
import 'nouislider';
const defaultConfig = {
activeElementClass: 'tts-active-content-node',
elementClass: 'tts-content-node',
left: -10,
maxPlaybackRate: 2,
minPlaybackRate: 0.5,
playbackRate: 1,
top: 50,
};
const stackingOptions = {
stackingScope: 'test-runner'
};
/**
* Creates an instance of Text to Speech component
*
* @param {Element} container
* @param {Object} config - component configurations
* @param {String} config.activeElementClass - class applied to active content element. Default value 'tts-active-content-node'
* @param {String} config.elementClass - class applied to content element. Default value 'tts-content-node'
* @param {Number} config.left - initial left position of component. Default value 50
* @param {Number} config.maxPlaybackRate - max playback rate. Default value 2
* @param {Number} config.minPlaybackRate - min playback rate. Default value 0.5
* @param {Number} config.playbackRate - playback rate. Default value 1
* @param {Number} config.top - initial top position of component. Default value 50
* @returns {ttsComponent} the textToSpeech component (uninitialized)
*/
function maskingComponentFactory(container, config) {
const audio = new Audio();
let currentPlayback = [];
let currentItem;
let mediaContentData = [];
let playbackRate;
// Browser does not support selection Api If getSelection is not defined
const selection = window.getSelection && window.getSelection();
// component API
const spec = {
/**
* Remove APIP element class and click handlers from APIP elements
*/
clearAPIPElements() {
const { elementClass } = this.config;
const $contentNodes = $(
mediaContentData
.map(({ selector }) => selector)
.join(', '),
container
);
$contentNodes.removeClass(elementClass);
$contentNodes.off('click', this.handleContentNodeClick);
},
/**
* Update componet state and stop playback
*
* @fires close
*/
close() {
this.setTTSStateOnContainer('playing', false);
this.setTTSStateOnContainer('sfhMode', false);
this.setState('settings', false);
this.stop();
this.trigger('close');
},
/**
* Get current active APIP item
*
* @returns {Object} active APIP item
*/
getCurrentItem() {
return currentItem;
},
/**
* When component in start from here mode, switch to clicked content element
*
* @param {Object} e - event object
*/
handleContentNodeClick(e) {
const $target = $(e.target);
// Allow default behaviour for inputs
if (
$target.hasClass('icon-checkbox')
|| $target.hasClass('icon-radio')
|| $target.is('input')
) {
return;
}
// Prevent default behaviour for lables and links
e.stopPropagation();
e.preventDefault();
if (!this.is('sfhMode')) {
return;
}
const $currentTarget = $(e.currentTarget);
// Find APIP item associated with clicked element
const selectedItemIndex = mediaContentData.findIndex(({ selector }) => $currentTarget.is(selector));
currentPlayback = mediaContentData.slice(selectedItemIndex);
this.stop();
this.initNextItem();
this.togglePlayback();
},
/**
* Select APIP item for default mode
*/
initDefaultModeItem() {
this.initItemWithTextSelection();
if (!currentItem) {
this.initDefaultModePlayback();
}
},
/**
* Check if there is some selected content inside APIP elelemts on the page
*/
initItemWithTextSelection() {
// Check if there is selected content
if (this.is('sfhMode') || !selection || !selection.toString()) {
return;
}
// Get APIP item by current selection
const currentSelection = selection.getRangeAt(0);
const { commonAncestorContainer } = currentSelection;
const selectedItem = mediaContentData.find(({ selector }) => {
const $item = $(selector, container);
return $item.is(commonAncestorContainer)
|| $.contains($item[0], commonAncestorContainer);
});
if (selectedItem && selectedItem !== currentItem) {
currentPlayback = [selectedItem];
this.initNextItem();
}
},
/**
* Check if there is next APIP item to play and start playback if component in playing state.
* If there is no APIP item to play stop playback
*
* @fires finish
* @fires next
*/
initNextItem() {
const { activeElementClass } = this.config;
currentItem && $(currentItem.selector, container).removeClass(activeElementClass);
currentItem = currentPlayback.shift();
if (currentItem) {
const { selector, url } = currentItem;
$(selector, container).addClass(activeElementClass);
audio.setAttribute('src', url);
audio.load();
audio.playbackRate = playbackRate;
if (this.is('playing')) {
audio.play();
}
this.trigger('next');
return;
}
this.trigger('finish');
this.stop();
},
/**
* Init default mode playback
*/
initDefaultModePlayback() {
currentPlayback = [...mediaContentData];
this.initNextItem();
},
/**
* Set APIP data. Apply handlers to APIP elements. Stop current playback
*
* @param {Array} data - APIP data items
*/
setMediaContentData(data) {
this.clearAPIPElements();
const { elementClass } = this.config;
mediaContentData = data;
const $contentNodes = $(
mediaContentData
.map(({ selector }) => selector)
.join(', '),
container
);
$contentNodes.addClass(elementClass);
$contentNodes.on('click', this.handleContentNodeClick);
this.stop();
},
/**
* Set playback rate
*
* @param {Object} e - event object
* @param {Number} value - playback rate
*/
setPlaybackRate(e, value) {
playbackRate = value;
audio.playbackRate = value;
},
/**
* Update component state. Toggle state class on page body
*
* @param {String} name
* @param {Boolean} value
*/
setTTSStateOnContainer(name, value) {
this.setState(name, value);
$(container).toggleClass(`tts-${name}`, value);
},
/**
* Pause playback and update component state. Set current item to null
*/
stop() {
const { activeElementClass } = this.config;
audio.pause();
audio.currentTime = 0;
currentItem && $(currentItem.selector, container).removeClass(activeElementClass);
currentItem = null;
this.setTTSStateOnContainer('playing', false);
},
/**
* Toggle playback
*
* @param {Object} e - event object
*/
togglePlayback(e) {
e && e.preventDefault();
const isPlaying = this.is('playing');
this.initDefaultModeItem();
if (!isPlaying && currentItem) {
audio.play();
this.setTTSStateOnContainer('playing', true);
} else {
audio.pause();
this.setTTSStateOnContainer('playing', false);
}
},
/**
* Toggle start from here mode
*/
toggleSFHMode() {
const isSFHMode = this.is('sfhMode');
this.setTTSStateOnContainer('sfhMode', !isSFHMode);
this.stop();
},
/**
* Toggle settings element
*/
toggleSettings() {
const isSettings = this.is('settings');
this.setState('settings', !isSettings);
// if settings was enabled make sure that component still inside the container
if (!isSettings) {
this.handleResize();
}
},
/**
* Handle browser resize
*/
handleResize() {
// offset from right
const offsetFromRight = 10;
const { x, y } = this.getPosition();
const maxXPosition = window.innerWidth - this.getElement().width() - offsetFromRight;
this.moveTo(x > maxXPosition ? maxXPosition : x, y);
}
};
const ttsComponent = component(spec, defaultConfig);
makePlaceable(ttsComponent);
makeStackable(ttsComponent, stackingOptions);
ttsComponent
.setTemplate(ttsTemplate)
.on('init', function () {
if (container.hasClass('tts-component-container')) {
throw new Error('Container already has assigned text to speech component');
}
container.addClass('tts-component-container');
this.render(container);
})
.on('render', function () {
let {
left,
maxPlaybackRate,
minPlaybackRate,
playbackRate: defaultPlaybackRate,
top
} = this.getConfig();
if (left < 0) {
left = window.innerWidth - this.getElement().width() + left;
}
const $element = this.getElement();
const $closeElement = $('.tts-control-close', $element);
const $dragElement = $('.tts-control-drag', $element);
const $playbackElement = $('.tts-control-playback', $element);
const $sfhModeElement = $('.tts-control-mode', $element);
const $sliderElement = $('.tts-slider', $element);
const $settingsElement = $('.tts-control-settings', $element);
playbackRate = defaultPlaybackRate;
$element.css('touch-action', 'none');
// make component dragable
const interactElement = interact($element)
.draggable({
autoScroll: true,
manualStart: true,
restrict: {
restriction: container[0],
elementRect: { left: 0, right: 1, top: 0, bottom: 1 }
},
onmove: (event) => {
const xOffset = Math.round(event.dx),
yOffset = Math.round(event.dy);
this.moveBy(xOffset, yOffset);
}
});
interact($dragElement[0]).on('down', (event) => {
const interaction = event.interaction;
interaction.start(
{
name: 'drag',
},
interactElement,
$element[0]
);
});
// initialise slider
$sliderElement.noUiSlider({
animate: true,
connected: true,
range: {
min: minPlaybackRate,
max: maxPlaybackRate,
},
start: defaultPlaybackRate,
step: 0.1
})
.on('change', this.setPlaybackRate);
// handle controls
$closeElement.on('click', this.close);
// handle mousedown instead of click to prevent selection lose
$playbackElement.on('mousedown touchstart', this.togglePlayback);
$sfhModeElement.on('click', this.toggleSFHMode);
$settingsElement.on('click', this.toggleSettings);
audio.addEventListener('ended', this.initNextItem);
audio.addEventListener('error', () => {
feedback().error(__('Can not playback media file!'));
this.initNextItem();
});
window.addEventListener('resize', this.handleResize);
// move to initial position
this.moveTo(left, top);
})
.on('hide', function () {
this.setTTSStateOnContainer('visible', false);
})
.on('show', function () {
this.setTTSStateOnContainer('visible', true);
})
.on('destroy', function () {
container.removeClass('tts-component-container');
this.clearAPIPElements();
this.stop();
window.removeEventListener('resize', this.handleResize);
});
ttsComponent.init(config);
return ttsComponent;
}
export default maskingComponentFactory;