357 lines
10 KiB
JavaScript
357 lines
10 KiB
JavaScript
var H5P = H5P || {};
|
|
|
|
/**
|
|
* Class responsible for creating speech bubbles
|
|
*/
|
|
H5P.JoubelSpeechBubble = (function ($) {
|
|
|
|
var $currentSpeechBubble;
|
|
var $currentContainer;
|
|
var $tail;
|
|
var $innerTail;
|
|
var removeSpeechBubbleTimeout;
|
|
var currentMaxWidth;
|
|
|
|
var DEFAULT_MAX_WIDTH = 400;
|
|
|
|
var iDevice = navigator.userAgent.match(/iPod|iPhone|iPad/g) ? true : false;
|
|
|
|
/**
|
|
* Creates a new speech bubble
|
|
*
|
|
* @param {H5P.jQuery} $container The speaking object
|
|
* @param {string} text The text to display
|
|
* @param {number} maxWidth The maximum width of the bubble
|
|
* @return {H5P.JoubelSpeechBubble}
|
|
*/
|
|
function JoubelSpeechBubble($container, text, maxWidth) {
|
|
maxWidth = maxWidth || DEFAULT_MAX_WIDTH;
|
|
currentMaxWidth = maxWidth;
|
|
$currentContainer = $container;
|
|
|
|
this.isCurrent = function ($tip) {
|
|
return $tip.is($currentContainer);
|
|
};
|
|
|
|
this.remove = function () {
|
|
remove();
|
|
};
|
|
|
|
var fadeOutSpeechBubble = function ($speechBubble) {
|
|
if (!$speechBubble) {
|
|
return;
|
|
}
|
|
|
|
// Stop removing bubble
|
|
clearTimeout(removeSpeechBubbleTimeout);
|
|
|
|
$speechBubble.removeClass('show');
|
|
setTimeout(function () {
|
|
if ($speechBubble) {
|
|
$speechBubble.remove();
|
|
$speechBubble = undefined;
|
|
}
|
|
}, 500);
|
|
};
|
|
|
|
if ($currentSpeechBubble !== undefined) {
|
|
remove();
|
|
}
|
|
|
|
var $h5pContainer = getH5PContainer($container);
|
|
|
|
// Make sure we fade out old speech bubble
|
|
fadeOutSpeechBubble($currentSpeechBubble);
|
|
|
|
// Create bubble
|
|
$tail = $('<div class="joubel-speech-bubble-tail"></div>');
|
|
$innerTail = $('<div class="joubel-speech-bubble-inner-tail"></div>');
|
|
var $innerBubble = $(
|
|
'<div class="joubel-speech-bubble-inner">' +
|
|
'<div class="joubel-speech-bubble-text">' + text + '</div>' +
|
|
'</div>'
|
|
).prepend($innerTail);
|
|
|
|
$currentSpeechBubble = $(
|
|
'<div class="joubel-speech-bubble" aria-live="assertive">'
|
|
).append([$tail, $innerBubble])
|
|
.appendTo($h5pContainer);
|
|
|
|
// Show speech bubble with transition
|
|
setTimeout(function () {
|
|
$currentSpeechBubble.addClass('show');
|
|
}, 0);
|
|
|
|
position($currentSpeechBubble, $currentContainer, maxWidth, $tail, $innerTail);
|
|
|
|
// Handle click to close
|
|
H5P.$body.on('mousedown.speechBubble', handleOutsideClick);
|
|
|
|
// Handle window resizing
|
|
H5P.$window.on('resize', '', handleResize);
|
|
|
|
// Handle clicks when inside IV which blocks bubbling.
|
|
$container.parents('.h5p-dialog')
|
|
.on('mousedown.speechBubble', handleOutsideClick);
|
|
|
|
if (iDevice) {
|
|
H5P.$body.css('cursor', 'pointer');
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
// Remove speechbubble if it belongs to a dom element that is about to be hidden
|
|
H5P.externalDispatcher.on('domHidden', function (event) {
|
|
if ($currentSpeechBubble !== undefined && event.data.$dom.find($currentContainer).length !== 0) {
|
|
remove();
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Returns the closest h5p container for the given DOM element.
|
|
*
|
|
* @param {object} $container jquery element
|
|
* @return {object} the h5p container (jquery element)
|
|
*/
|
|
function getH5PContainer($container) {
|
|
var $h5pContainer = $container.closest('.h5p-frame');
|
|
|
|
// Check closest h5p frame first, then check for container in case there is no frame.
|
|
if (!$h5pContainer.length) {
|
|
$h5pContainer = $container.closest('.h5p-container');
|
|
}
|
|
|
|
return $h5pContainer;
|
|
}
|
|
|
|
/**
|
|
* Event handler that is called when the window is resized.
|
|
*/
|
|
function handleResize() {
|
|
position($currentSpeechBubble, $currentContainer, currentMaxWidth, $tail, $innerTail);
|
|
}
|
|
|
|
/**
|
|
* Repositions the speech bubble according to the position of the container.
|
|
*
|
|
* @param {object} $currentSpeechbubble the speech bubble that should be positioned
|
|
* @param {object} $container the container to which the speech bubble should point
|
|
* @param {number} maxWidth the maximum width of the speech bubble
|
|
* @param {object} $tail the tail (the triangle that points to the referenced container)
|
|
* @param {object} $innerTail the inner tail (the triangle that points to the referenced container)
|
|
*/
|
|
function position($currentSpeechBubble, $container, maxWidth, $tail, $innerTail) {
|
|
var $h5pContainer = getH5PContainer($container);
|
|
|
|
// Calculate offset between the button and the h5p frame
|
|
var offset = getOffsetBetween($h5pContainer, $container);
|
|
|
|
var direction = (offset.bottom > offset.top ? 'bottom' : 'top');
|
|
var tipWidth = offset.outerWidth * 0.9; // Var needs to be renamed to make sense
|
|
var bubbleWidth = tipWidth > maxWidth ? maxWidth : tipWidth;
|
|
|
|
var bubblePosition = getBubblePosition(bubbleWidth, offset);
|
|
var tailPosition = getTailPosition(bubbleWidth, bubblePosition, offset, $container.width());
|
|
// Need to set font-size, since element is appended to body.
|
|
// Using same font-size as parent. In that way it will grow accordingly
|
|
// when resizing
|
|
var fontSize = 16;//parseFloat($parent.css('font-size'));
|
|
|
|
// Set width and position of speech bubble
|
|
$currentSpeechBubble.css(bubbleCSS(
|
|
direction,
|
|
bubbleWidth,
|
|
bubblePosition,
|
|
fontSize
|
|
));
|
|
|
|
var preparedTailCSS = tailCSS(direction, tailPosition);
|
|
$tail.css(preparedTailCSS);
|
|
$innerTail.css(preparedTailCSS);
|
|
}
|
|
|
|
/**
|
|
* Static function for removing the speechbubble
|
|
*/
|
|
var remove = function () {
|
|
H5P.$body.off('mousedown.speechBubble');
|
|
H5P.$window.off('resize', '', handleResize);
|
|
$currentContainer.parents('.h5p-dialog').off('mousedown.speechBubble');
|
|
if (iDevice) {
|
|
H5P.$body.css('cursor', '');
|
|
}
|
|
if ($currentSpeechBubble !== undefined) {
|
|
// Apply transition, then remove speech bubble
|
|
$currentSpeechBubble.removeClass('show');
|
|
|
|
// Make sure we remove any old timeout before reassignment
|
|
clearTimeout(removeSpeechBubbleTimeout);
|
|
removeSpeechBubbleTimeout = setTimeout(function () {
|
|
$currentSpeechBubble.remove();
|
|
$currentSpeechBubble = undefined;
|
|
}, 500);
|
|
}
|
|
// Don't return false here. If the user e.g. clicks a button when the bubble is visible,
|
|
// we want the bubble to disapear AND the button to receive the event
|
|
};
|
|
|
|
/**
|
|
* Remove the speech bubble and container reference
|
|
*/
|
|
function handleOutsideClick(event) {
|
|
if (event.target === $currentContainer[0]) {
|
|
return; // Button clicks are not outside clicks
|
|
}
|
|
|
|
remove();
|
|
// There is no current container when a container isn't clicked
|
|
$currentContainer = undefined;
|
|
}
|
|
|
|
/**
|
|
* Calculate position for speech bubble
|
|
*
|
|
* @param {number} bubbleWidth The width of the speech bubble
|
|
* @param {object} offset
|
|
* @return {object} Return position for the speech bubble
|
|
*/
|
|
function getBubblePosition(bubbleWidth, offset) {
|
|
var bubblePosition = {};
|
|
|
|
var tailOffset = 9;
|
|
var widthOffset = bubbleWidth / 2;
|
|
|
|
// Calculate top position
|
|
bubblePosition.top = offset.top + offset.innerHeight;
|
|
|
|
// Calculate bottom position
|
|
bubblePosition.bottom = offset.bottom + offset.innerHeight + tailOffset;
|
|
|
|
// Calculate left position
|
|
if (offset.left < widthOffset) {
|
|
bubblePosition.left = 3;
|
|
}
|
|
else if ((offset.left + widthOffset) > offset.outerWidth) {
|
|
bubblePosition.left = offset.outerWidth - bubbleWidth - 3;
|
|
}
|
|
else {
|
|
bubblePosition.left = offset.left - widthOffset + (offset.innerWidth / 2);
|
|
}
|
|
|
|
return bubblePosition;
|
|
}
|
|
|
|
/**
|
|
* Calculate position for speech bubble tail
|
|
*
|
|
* @param {number} bubbleWidth The width of the speech bubble
|
|
* @param {object} bubblePosition Speech bubble position
|
|
* @param {object} offset
|
|
* @param {number} iconWidth The width of the tip icon
|
|
* @return {object} Return position for the tail
|
|
*/
|
|
function getTailPosition(bubbleWidth, bubblePosition, offset, iconWidth) {
|
|
var tailPosition = {};
|
|
// Magic numbers. Tuned by hand so that the tail fits visually within
|
|
// the bounds of the speech bubble.
|
|
var leftBoundary = 9;
|
|
var rightBoundary = bubbleWidth - 20;
|
|
|
|
tailPosition.left = offset.left - bubblePosition.left + (iconWidth / 2) - 6;
|
|
if (tailPosition.left < leftBoundary) {
|
|
tailPosition.left = leftBoundary;
|
|
}
|
|
if (tailPosition.left > rightBoundary) {
|
|
tailPosition.left = rightBoundary;
|
|
}
|
|
|
|
tailPosition.top = -6;
|
|
tailPosition.bottom = -6;
|
|
|
|
return tailPosition;
|
|
}
|
|
|
|
/**
|
|
* Return bubble CSS for the desired growth direction
|
|
*
|
|
* @param {string} direction The direction the speech bubble will grow
|
|
* @param {number} width The width of the speech bubble
|
|
* @param {object} position Speech bubble position
|
|
* @param {number} fontSize The size of the bubbles font
|
|
* @return {object} Return CSS
|
|
*/
|
|
function bubbleCSS(direction, width, position, fontSize) {
|
|
if (direction === 'top') {
|
|
return {
|
|
width: width + 'px',
|
|
bottom: position.bottom + 'px',
|
|
left: position.left + 'px',
|
|
fontSize: fontSize + 'px',
|
|
top: ''
|
|
};
|
|
}
|
|
else {
|
|
return {
|
|
width: width + 'px',
|
|
top: position.top + 'px',
|
|
left: position.left + 'px',
|
|
fontSize: fontSize + 'px',
|
|
bottom: ''
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return tail CSS for the desired growth direction
|
|
*
|
|
* @param {string} direction The direction the speech bubble will grow
|
|
* @param {object} position Tail position
|
|
* @return {object} Return CSS
|
|
*/
|
|
function tailCSS(direction, position) {
|
|
if (direction === 'top') {
|
|
return {
|
|
bottom: position.bottom + 'px',
|
|
left: position.left + 'px',
|
|
top: ''
|
|
};
|
|
}
|
|
else {
|
|
return {
|
|
top: position.top + 'px',
|
|
left: position.left + 'px',
|
|
bottom: ''
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates the offset between an element inside a container and the
|
|
* container. Only works if all the edges of the inner element are inside the
|
|
* outer element.
|
|
* Width/height of the elements is included as a convenience.
|
|
*
|
|
* @param {H5P.jQuery} $outer
|
|
* @param {H5P.jQuery} $inner
|
|
* @return {object} Position offset
|
|
*/
|
|
function getOffsetBetween($outer, $inner) {
|
|
var outer = $outer[0].getBoundingClientRect();
|
|
var inner = $inner[0].getBoundingClientRect();
|
|
|
|
return {
|
|
top: inner.top - outer.top,
|
|
right: outer.right - inner.right,
|
|
bottom: outer.bottom - inner.bottom,
|
|
left: inner.left - outer.left,
|
|
innerWidth: inner.width,
|
|
innerHeight: inner.height,
|
|
outerWidth: outer.width,
|
|
outerHeight: outer.height
|
|
};
|
|
}
|
|
|
|
return JoubelSpeechBubble;
|
|
})(H5P.jQuery);
|