/*jshint multistr: true */ // TODO: Should we split up the generic parts needed by the editor(and others), and the parts needed to "run" H5Ps? /** @namespace */ var H5P = window.H5P = window.H5P || {}; /** * Tells us if we're inside of an iframe. * @member {boolean} */ H5P.isFramed = (window.self !== window.parent); /** * jQuery instance of current window. * @member {H5P.jQuery} */ H5P.$window = H5P.jQuery(window); /** * List over H5P instances on the current page. * @member {Array} */ H5P.instances = []; // Detect if we support fullscreen, and what prefix to use. if (document.documentElement.requestFullScreen) { /** * Browser prefix to use when entering fullscreen mode. * undefined means no fullscreen support. * @member {string} */ H5P.fullScreenBrowserPrefix = ''; } else if (document.documentElement.webkitRequestFullScreen) { H5P.safariBrowser = navigator.userAgent.match(/version\/([.\d]+)/i); H5P.safariBrowser = (H5P.safariBrowser === null ? 0 : parseInt(H5P.safariBrowser[1])); // Do not allow fullscreen for safari < 7. if (H5P.safariBrowser === 0 || H5P.safariBrowser > 6) { H5P.fullScreenBrowserPrefix = 'webkit'; } } else if (document.documentElement.mozRequestFullScreen) { H5P.fullScreenBrowserPrefix = 'moz'; } else if (document.documentElement.msRequestFullscreen) { H5P.fullScreenBrowserPrefix = 'ms'; } /** * Keep track of when the H5Ps where started. * * @type {Object[]} */ H5P.opened = {}; /** * Initialize H5P content. * Scans for ".h5p-content" in the document and initializes H5P instances where found. * * @param {Object} target DOM Element */ H5P.init = function (target) { // Useful jQuery object. if (H5P.$body === undefined) { H5P.$body = H5P.jQuery(document.body); } // Determine if we can use full screen if (H5P.fullscreenSupported === undefined) { /** * Use this variable to check if fullscreen is supported. Fullscreen can be * restricted when embedding since not all browsers support the native * fullscreen, and the semi-fullscreen solution doesn't work when embedded. * @type {boolean} */ H5P.fullscreenSupported = !H5PIntegration.fullscreenDisabled && !H5P.fullscreenDisabled && (!(H5P.isFramed && H5P.externalEmbed !== false) || !!(document.fullscreenEnabled || document.webkitFullscreenEnabled || document.mozFullScreenEnabled)); // -We should consider document.msFullscreenEnabled when they get their // -element sizing corrected. Ref. https://connect.microsoft.com/IE/feedback/details/838286/ie-11-incorrectly-reports-dom-element-sizes-in-fullscreen-mode-when-fullscreened-element-is-within-an-iframe // Update: Seems to be no need as they've moved on to Webkit } // Determine if we can use local storage if (H5P.localStorageSupported === undefined) { try { H5P.localStorageSupported = (window.localStorage) ? true : false; } catch (error) { H5P.localStorageSupported = false; } } // Deprecated variable, kept to maintain backwards compatability if (H5P.canHasFullScreen === undefined) { /** * @deprecated since version 1.11 * @type {boolean} */ H5P.canHasFullScreen = H5P.fullscreenSupported; } // H5Ps added in normal DIV. H5P.jQuery('.h5p-content:not(.h5p-initialized)', target).each(function () { var $element = H5P.jQuery(this).addClass('h5p-initialized'); var $container = H5P.jQuery('
').appendTo($element); var contentId = $element.data('content-id'); var contentData = H5PIntegration.contents['cid-' + contentId]; if (contentData === undefined) { return H5P.error('No data for content id ' + contentId + '. Perhaps the library is gone?'); } var library = { library: contentData.library, params: JSON.parse(contentData.jsonContent), metadata: contentData.metadata }; H5P.getUserData(contentId, 'state', function (err, previousState) { if (previousState) { library.userDatas = { state: previousState }; } else if (previousState === null && H5PIntegration.saveFreq) { // Content has been reset. Display dialog. delete contentData.contentUserData; var dialog = new H5P.Dialog('content-user-data-reset', 'Data Reset', '

' + H5P.t('contentChanged') + '

' + H5P.t('startingOver') + '

OK
', $container); H5P.jQuery(dialog).on('dialog-opened', function (event, $dialog) { var closeDialog = function (event) { if (event.type === 'click' || event.which === 32) { dialog.close(); H5P.deleteUserData(contentId, 'state', 0); } }; $dialog.find('.h5p-dialog-ok-button').click(closeDialog).keypress(closeDialog); H5P.trigger(instance, 'resize'); }).on('dialog-closed', function () { H5P.trigger(instance, 'resize'); }); dialog.open(); } // If previousState is false we don't have a previous state }); // Create new instance. var instance = H5P.newRunnable(library, contentId, $container, true, {standalone: true}); H5P.offlineRequestQueue = new H5P.OfflineRequestQueue({instance: instance}); // Check if we should add and display a fullscreen button for this H5P. if (contentData.fullScreen == 1 && H5P.fullscreenSupported) { H5P.jQuery( '
' + '
' + '
' + '
') .prependTo($container) .children() .click(function () { H5P.fullScreen($container, instance); }) .keydown(function (e) { if (e.which === 32 || e.which === 13) { H5P.fullScreen($container, instance); return false; } }) ; } /** * Create action bar */ var displayOptions = contentData.displayOptions; var displayFrame = false; if (displayOptions.frame) { // Special handling of copyrights if (displayOptions.copyright) { var copyrights = H5P.getCopyrights(instance, library.params, contentId, library.metadata); if (!copyrights) { displayOptions.copyright = false; } } // Create action bar var actionBar = new H5P.ActionBar(displayOptions); var $actions = actionBar.getDOMElement(); actionBar.on('reuse', function () { H5P.openReuseDialog($actions, contentData, library, instance, contentId); instance.triggerXAPI('accessed-reuse'); }); actionBar.on('copyrights', function () { var dialog = new H5P.Dialog('copyrights', H5P.t('copyrightInformation'), copyrights, $container); dialog.open(true); instance.triggerXAPI('accessed-copyright'); }); actionBar.on('embed', function () { H5P.openEmbedDialog($actions, contentData.embedCode, contentData.resizeCode, { width: $element.width(), height: $element.height() }, instance); instance.triggerXAPI('accessed-embed'); }); if (actionBar.hasActions()) { displayFrame = true; $actions.insertAfter($container); } } $element.addClass(displayFrame ? 'h5p-frame' : 'h5p-no-frame'); // Keep track of when we started H5P.opened[contentId] = new Date(); // Handle events when the user finishes the content. Useful for logging exercise results. H5P.on(instance, 'finish', function (event) { if (event.data !== undefined) { H5P.setFinished(contentId, event.data.score, event.data.maxScore, event.data.time); } }); // Listen for xAPI events. H5P.on(instance, 'xAPI', H5P.xAPICompletedListener); // Auto save current state if supported if (H5PIntegration.saveFreq !== false && ( instance.getCurrentState instanceof Function || typeof instance.getCurrentState === 'function')) { var saveTimer, save = function () { var state = instance.getCurrentState(); if (state !== undefined) { H5P.setUserData(contentId, 'state', state, {deleteOnChange: true}); } if (H5PIntegration.saveFreq) { // Continue autosave saveTimer = setTimeout(save, H5PIntegration.saveFreq * 1000); } }; if (H5PIntegration.saveFreq) { // Start autosave saveTimer = setTimeout(save, H5PIntegration.saveFreq * 1000); } // xAPI events will schedule a save in three seconds. H5P.on(instance, 'xAPI', function (event) { var verb = event.getVerb(); if (verb === 'completed' || verb === 'progressed') { clearTimeout(saveTimer); saveTimer = setTimeout(save, 3000); } }); } if (H5P.isFramed) { var resizeDelay; if (H5P.externalEmbed === false) { // Internal embed // Make it possible to resize the iframe when the content changes size. This way we get no scrollbars. var iframe = window.frameElement; var resizeIframe = function () { if (window.parent.H5P.isFullscreen) { return; // Skip if full screen. } // Retain parent size to avoid jumping/scrolling var parentHeight = iframe.parentElement.style.height; iframe.parentElement.style.height = iframe.parentElement.clientHeight + 'px'; // Note: Force layout reflow // This fixes a flickering bug for embedded content on iPads // @see https://github.com/h5p/h5p-moodle-plugin/issues/237 iframe.getBoundingClientRect(); // Reset iframe height, in case content has shrinked. iframe.style.height = '1px'; // Resize iframe so all content is visible. iframe.style.height = (iframe.contentDocument.body.scrollHeight) + 'px'; // Free parent iframe.parentElement.style.height = parentHeight; }; H5P.on(instance, 'resize', function () { // Use a delay to make sure iframe is resized to the correct size. clearTimeout(resizeDelay); resizeDelay = setTimeout(function () { resizeIframe(); }, 1); }); } else if (H5P.communicator) { // External embed var parentIsFriendly = false; // Handle that the resizer is loaded after the iframe H5P.communicator.on('ready', function () { H5P.communicator.send('hello'); }); // Handle hello message from our parent window H5P.communicator.on('hello', function () { // Initial setup/handshake is done parentIsFriendly = true; // Make iframe responsive document.body.style.height = 'auto'; // Hide scrollbars for correct size document.body.style.overflow = 'hidden'; // Content need to be resized to fit the new iframe size H5P.trigger(instance, 'resize'); }); // When resize has been prepared tell parent window to resize H5P.communicator.on('resizePrepared', function () { H5P.communicator.send('resize', { scrollHeight: document.body.scrollHeight }); }); H5P.communicator.on('resize', function () { H5P.trigger(instance, 'resize'); }); H5P.on(instance, 'resize', function () { if (H5P.isFullscreen) { return; // Skip iframe resize } // Use a delay to make sure iframe is resized to the correct size. clearTimeout(resizeDelay); resizeDelay = setTimeout(function () { // Only resize if the iframe can be resized if (parentIsFriendly) { H5P.communicator.send('prepareResize', { scrollHeight: document.body.scrollHeight, clientHeight: document.body.clientHeight }); } else { H5P.communicator.send('hello'); } }, 0); }); } } if (!H5P.isFramed || H5P.externalEmbed === false) { // Resize everything when window is resized. H5P.jQuery(window.parent).resize(function () { if (window.parent.H5P.isFullscreen) { // Use timeout to avoid bug in certain browsers when exiting fullscreen. Some browser will trigger resize before the fullscreenchange event. H5P.trigger(instance, 'resize'); } else { H5P.trigger(instance, 'resize'); } }); } H5P.instances.push(instance); // Resize content. H5P.trigger(instance, 'resize'); // Logic for hiding focus effects when using mouse $element.addClass('using-mouse'); $element.on('mousedown keydown keyup', function (event) { $element.toggleClass('using-mouse', event.type === 'mousedown'); }); if (H5P.externalDispatcher) { H5P.externalDispatcher.trigger('initialized'); } }); // Insert H5Ps that should be in iframes. H5P.jQuery('iframe.h5p-iframe:not(.h5p-initialized)', target).each(function () { var contentId = H5P.jQuery(this).addClass('h5p-initialized').data('content-id'); this.contentDocument.open(); this.contentDocument.write('' + H5P.getHeadTags(contentId) + '
'); this.contentDocument.close(); }); }; /** * Loop through assets for iframe content and create a set of tags for head. * * @private * @param {number} contentId * @returns {string} HTML */ H5P.getHeadTags = function (contentId) { var createStyleTags = function (styles) { var tags = ''; for (var i = 0; i < styles.length; i++) { tags += ''; } return tags; }; var createScriptTags = function (scripts) { var tags = ''; for (var i = 0; i < scripts.length; i++) { tags += ''; } return tags; }; return '' + createStyleTags(H5PIntegration.core.styles) + createStyleTags(H5PIntegration.contents['cid-' + contentId].styles) + createScriptTags(H5PIntegration.core.scripts) + createScriptTags(H5PIntegration.contents['cid-' + contentId].scripts) + ''; }; /** * When embedded the communicator helps talk to the parent page. * * @type {Communicator} */ H5P.communicator = (function () { /** * @class * @private */ function Communicator() { var self = this; // Maps actions to functions var actionHandlers = {}; // Register message listener window.addEventListener('message', function receiveMessage(event) { if (window.parent !== event.source || event.data.context !== 'h5p') { return; // Only handle messages from parent and in the correct context } if (actionHandlers[event.data.action] !== undefined) { actionHandlers[event.data.action](event.data); } } , false); /** * Register action listener. * * @param {string} action What you are waiting for * @param {function} handler What you want done */ self.on = function (action, handler) { actionHandlers[action] = handler; }; /** * Send a message to the all mighty father. * * @param {string} action * @param {Object} [data] payload */ self.send = function (action, data) { if (data === undefined) { data = {}; } data.context = 'h5p'; data.action = action; // Parent origin can be anything window.parent.postMessage(data, '*'); }; } return (window.postMessage && window.addEventListener ? new Communicator() : undefined); })(); /** * Enter semi fullscreen for the given H5P instance * * @param {H5P.jQuery} $element Content container. * @param {Object} instance * @param {function} exitCallback Callback function called when user exits fullscreen. * @param {H5P.jQuery} $body For internal use. Gives the body of the iframe. */ H5P.semiFullScreen = function ($element, instance, exitCallback, body) { H5P.fullScreen($element, instance, exitCallback, body, true); }; /** * Enter fullscreen for the given H5P instance. * * @param {H5P.jQuery} $element Content container. * @param {Object} instance * @param {function} exitCallback Callback function called when user exits fullscreen. * @param {H5P.jQuery} $body For internal use. Gives the body of the iframe. * @param {Boolean} forceSemiFullScreen */ H5P.fullScreen = function ($element, instance, exitCallback, body, forceSemiFullScreen) { if (H5P.exitFullScreen !== undefined) { return; // Cannot enter new fullscreen until previous is over } if (H5P.isFramed && H5P.externalEmbed === false) { // Trigger resize on wrapper in parent window. window.parent.H5P.fullScreen($element, instance, exitCallback, H5P.$body.get(), forceSemiFullScreen); H5P.isFullscreen = true; H5P.exitFullScreen = function () { window.parent.H5P.exitFullScreen(); }; H5P.on(instance, 'exitFullScreen', function () { H5P.isFullscreen = false; H5P.exitFullScreen = undefined; }); return; } var $container = $element; var $classes, $iframe, $body; if (body === undefined) { $body = H5P.$body; } else { // We're called from an iframe. $body = H5P.jQuery(body); $classes = $body.add($element.get()); var iframeSelector = '#h5p-iframe-' + $element.parent().data('content-id'); $iframe = H5P.jQuery(iframeSelector); $element = $iframe.parent(); // Put iframe wrapper in fullscreen, not container. } $classes = $element.add(H5P.$body).add($classes); /** * Prepare for resize by setting the correct styles. * * @private * @param {string} classes CSS */ var before = function (classes) { $classes.addClass(classes); if ($iframe !== undefined) { // Set iframe to its default size(100%). $iframe.css('height', ''); } }; /** * Gets called when fullscreen mode has been entered. * Resizes and sets focus on content. * * @private */ var entered = function () { // Do not rely on window resize events. H5P.trigger(instance, 'resize'); H5P.trigger(instance, 'focus'); H5P.trigger(instance, 'enterFullScreen'); }; /** * Gets called when fullscreen mode has been exited. * Resizes and sets focus on content. * * @private * @param {string} classes CSS */ var done = function (classes) { H5P.isFullscreen = false; $classes.removeClass(classes); // Do not rely on window resize events. H5P.trigger(instance, 'resize'); H5P.trigger(instance, 'focus'); H5P.exitFullScreen = undefined; if (exitCallback !== undefined) { exitCallback(); } H5P.trigger(instance, 'exitFullScreen'); }; H5P.isFullscreen = true; if (H5P.fullScreenBrowserPrefix === undefined || forceSemiFullScreen === true) { // Create semi fullscreen. if (H5P.isFramed) { return; // TODO: Should we support semi-fullscreen for IE9 & 10 ? } before('h5p-semi-fullscreen'); var $disable = H5P.jQuery('
').appendTo($container.find('.h5p-content-controls')); var keyup, disableSemiFullscreen = H5P.exitFullScreen = function () { if (prevViewportContent) { // Use content from the previous viewport tag h5pViewport.content = prevViewportContent; } else { // Remove viewport tag head.removeChild(h5pViewport); } $disable.remove(); $body.unbind('keyup', keyup); done('h5p-semi-fullscreen'); }; keyup = function (event) { if (event.keyCode === 27) { disableSemiFullscreen(); } }; $disable.click(disableSemiFullscreen); $body.keyup(keyup); // Disable zoom var prevViewportContent, h5pViewport; var metaTags = document.getElementsByTagName('meta'); for (var i = 0; i < metaTags.length; i++) { if (metaTags[i].name === 'viewport') { // Use the existing viewport tag h5pViewport = metaTags[i]; prevViewportContent = h5pViewport.content; break; } } if (!prevViewportContent) { // Create a new viewport tag h5pViewport = document.createElement('meta'); h5pViewport.name = 'viewport'; } h5pViewport.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0'; if (!prevViewportContent) { // Insert the new viewport tag var head = document.getElementsByTagName('head')[0]; head.appendChild(h5pViewport); } entered(); } else { // Create real fullscreen. before('h5p-fullscreen'); var first, eventName = (H5P.fullScreenBrowserPrefix === 'ms' ? 'MSFullscreenChange' : H5P.fullScreenBrowserPrefix + 'fullscreenchange'); document.addEventListener(eventName, function () { if (first === undefined) { // We are entering fullscreen mode first = false; entered(); return; } // We are exiting fullscreen done('h5p-fullscreen'); document.removeEventListener(eventName, arguments.callee, false); }); if (H5P.fullScreenBrowserPrefix === '') { $element[0].requestFullScreen(); } else { var method = (H5P.fullScreenBrowserPrefix === 'ms' ? 'msRequestFullscreen' : H5P.fullScreenBrowserPrefix + 'RequestFullScreen'); var params = (H5P.fullScreenBrowserPrefix === 'webkit' && H5P.safariBrowser === 0 ? Element.ALLOW_KEYBOARD_INPUT : undefined); $element[0][method](params); } // Allows everone to exit H5P.exitFullScreen = function () { if (H5P.fullScreenBrowserPrefix === '') { document.exitFullscreen(); } else if (H5P.fullScreenBrowserPrefix === 'moz') { document.mozCancelFullScreen(); } else { document[H5P.fullScreenBrowserPrefix + 'ExitFullscreen'](); } }; } }; (function () { /** * Helper for adding a query parameter to an existing path that may already * contain one or a hash. * * @param {string} path * @param {string} parameter * @return {string} */ H5P.addQueryParameter = function (path, parameter) { let newPath, secondSplit; const firstSplit = path.split('?'); if (firstSplit[1]) { // There is already an existing query secondSplit = firstSplit[1].split('#'); newPath = firstSplit[0] + '?' + secondSplit[0] + '&'; } else { // No existing query, just need to take care of the hash secondSplit = firstSplit[0].split('#'); newPath = secondSplit[0] + '?'; } newPath += parameter; if (secondSplit[1]) { // Add back the hash newPath += '#' + secondSplit[1]; } return newPath; }; /** * Helper for setting the crossOrigin attribute + the complete correct source. * Note: This will start loading the resource. * * @param {Element} element DOM element, typically img, video or audio * @param {Object} source File object from parameters/json_content (created by H5PEditor) * @param {number} contentId Needed to determine the complete correct file path */ H5P.setSource = function (element, source, contentId) { let path = source.path; const crossOrigin = H5P.getCrossOrigin(source); if (crossOrigin) { element.crossOrigin = crossOrigin; if (H5PIntegration.crossoriginCacheBuster) { // Some sites may want to add a cache buster in case the same resource // is used elsewhere without the crossOrigin attribute path = H5P.addQueryParameter(path, H5PIntegration.crossoriginCacheBuster); } } else { // In case this element has been used before. element.removeAttribute('crossorigin'); } element.src = H5P.getPath(path, contentId); }; /** * Check if the given path has a protocol. * * @private * @param {string} path * @return {string} */ var hasProtocol = function (path) { return path.match(/^[a-z0-9]+:\/\//i); }; /** * Get the crossOrigin policy to use for img, video and audio tags on the current site. * * @param {Object|string} source File object from parameters/json_content - Can also be URL(deprecated usage) * @returns {string|null} crossOrigin attribute value required by the source */ H5P.getCrossOrigin = function (source) { if (typeof source !== 'object') { // Deprecated usage. return H5PIntegration.crossorigin && H5PIntegration.crossoriginRegex && source.match(H5PIntegration.crossoriginRegex) ? H5PIntegration.crossorigin : null; } if (H5PIntegration.crossorigin && !hasProtocol(source.path)) { // This is a local file, use the local crossOrigin policy. return H5PIntegration.crossorigin; // Note: We cannot use this for all external sources since we do not know // each server's individual policy. We could add support for a list of // external sources and their policy later on. } }; /** * Find the path to the content files based on the id of the content. * Also identifies and returns absolute paths. * * @param {string} path * Relative to content folder or absolute. * @param {number} contentId * ID of the content requesting the path. * @returns {string} * Complete URL to path. */ H5P.getPath = function (path, contentId) { if (hasProtocol(path)) { return path; } var prefix; var isTmpFile = (path.substr(-4,4) === '#tmp'); if (contentId !== undefined && !isTmpFile) { // Check for custom override URL if (H5PIntegration.contents !== undefined && H5PIntegration.contents['cid-' + contentId]) { prefix = H5PIntegration.contents['cid-' + contentId].contentUrl; } if (!prefix) { prefix = H5PIntegration.url + '/content/' + contentId; } } else if (window.H5PEditor !== undefined) { prefix = H5PEditor.filesPath; } else { return; } if (!hasProtocol(prefix)) { // Use absolute urls prefix = window.location.protocol + "//" + window.location.host + prefix; } return prefix + '/' + path; }; })(); /** * THIS FUNCTION IS DEPRECATED, USE getPath INSTEAD * Will be remove march 2016. * * Find the path to the content files folder based on the id of the content * * @deprecated * Will be removed march 2016. * @param contentId * Id of the content requesting the path * @returns {string} * URL */ H5P.getContentPath = function (contentId) { return H5PIntegration.url + '/content/' + contentId; }; /** * Get library class constructor from H5P by classname. * Note that this class will only work for resolve "H5P.NameWithoutDot". * Also check out {@link H5P.newRunnable} * * Used from libraries to construct instances of other libraries' objects by name. * * @param {string} name Name of library * @returns {Object} Class constructor */ H5P.classFromName = function (name) { var arr = name.split("."); return this[arr[arr.length - 1]]; }; /** * A safe way of creating a new instance of a runnable H5P. * * @param {Object} library * Library/action object form params. * @param {number} contentId * Identifies the content. * @param {H5P.jQuery} [$attachTo] * Element to attach the instance to. * @param {boolean} [skipResize] * Skip triggering of the resize event after attaching. * @param {Object} [extras] * Extra parameters for the H5P content constructor * @returns {Object} * Instance. */ H5P.newRunnable = function (library, contentId, $attachTo, skipResize, extras) { var nameSplit, versionSplit, machineName; try { nameSplit = library.library.split(' ', 2); machineName = nameSplit[0]; versionSplit = nameSplit[1].split('.', 2); } catch (err) { return H5P.error('Invalid library string: ' + library.library); } if ((library.params instanceof Object) !== true || (library.params instanceof Array) === true) { H5P.error('Invalid library params for: ' + library.library); return H5P.error(library.params); } // Find constructor function var constructor; try { nameSplit = nameSplit[0].split('.'); constructor = window; for (var i = 0; i < nameSplit.length; i++) { constructor = constructor[nameSplit[i]]; } if (typeof constructor !== 'function') { throw null; } } catch (err) { return H5P.error('Unable to find constructor for: ' + library.library); } if (extras === undefined) { extras = {}; } if (library.subContentId) { extras.subContentId = library.subContentId; } if (library.userDatas && library.userDatas.state && H5PIntegration.saveFreq) { extras.previousState = library.userDatas.state; } if (library.metadata) { extras.metadata = library.metadata; } // Makes all H5P libraries extend H5P.ContentType: var standalone = extras.standalone || false; // This order makes it possible for an H5P library to override H5P.ContentType functions! constructor.prototype = H5P.jQuery.extend({}, H5P.ContentType(standalone).prototype, constructor.prototype); var instance; // Some old library versions have their own custom third parameter. // Make sure we don't send them the extras. // (they will interpret it as something else) if (H5P.jQuery.inArray(library.library, ['H5P.CoursePresentation 1.0', 'H5P.CoursePresentation 1.1', 'H5P.CoursePresentation 1.2', 'H5P.CoursePresentation 1.3']) > -1) { instance = new constructor(library.params, contentId); } else { instance = new constructor(library.params, contentId, extras); } if (instance.$ === undefined) { instance.$ = H5P.jQuery(instance); } if (instance.contentId === undefined) { instance.contentId = contentId; } if (instance.subContentId === undefined && library.subContentId) { instance.subContentId = library.subContentId; } if (instance.parent === undefined && extras && extras.parent) { instance.parent = extras.parent; } if (instance.libraryInfo === undefined) { instance.libraryInfo = { versionedName: library.library, versionedNameNoSpaces: machineName + '-' + versionSplit[0] + '.' + versionSplit[1], machineName: machineName, majorVersion: versionSplit[0], minorVersion: versionSplit[1] }; } if ($attachTo !== undefined) { $attachTo.toggleClass('h5p-standalone', standalone); instance.attach($attachTo); H5P.trigger(instance, 'domChanged', { '$target': $attachTo, 'library': machineName, 'key': 'newLibrary' }, {'bubbles': true, 'external': true}); if (skipResize === undefined || !skipResize) { // Resize content. H5P.trigger(instance, 'resize'); } } return instance; }; /** * Used to print useful error messages. (to JavaScript error console) * * @param {*} err Error to print. */ H5P.error = function (err) { if (window.console !== undefined && console.error !== undefined) { console.error(err.stack ? err.stack : err); } }; /** * Translate text strings. * * @param {string} key * Translation identifier, may only contain a-zA-Z0-9. No spaces or special chars. * @param {Object} [vars] * Data for placeholders. * @param {string} [ns] * Translation namespace. Defaults to H5P. * @returns {string} * Translated text */ H5P.t = function (key, vars, ns) { if (ns === undefined) { ns = 'H5P'; } if (H5PIntegration.l10n[ns] === undefined) { return '[Missing translation namespace "' + ns + '"]'; } if (H5PIntegration.l10n[ns][key] === undefined) { return '[Missing translation "' + key + '" in "' + ns + '"]'; } var translation = H5PIntegration.l10n[ns][key]; if (vars !== undefined) { // Replace placeholder with variables. for (var placeholder in vars) { translation = translation.replace(placeholder, vars[placeholder]); } } return translation; }; /** * Creates a new popup dialog over the H5P content. * * @class * @param {string} name * Used for html class. * @param {string} title * Used for header. * @param {string} content * Displayed inside the dialog. * @param {H5P.jQuery} $element * Which DOM element the dialog should be inserted after. */ H5P.Dialog = function (name, title, content, $element) { /** @alias H5P.Dialog# */ var self = this; var $dialog = H5P.jQuery('') .insertAfter($element) .click(function (e) { if (e && e.originalEvent && e.originalEvent.preventClosing) { return; } self.close(); }) .children('.h5p-inner') .click(function (e) { e.originalEvent.preventClosing = true; }) .find('.h5p-close') .click(function () { self.close(); }) .keypress(function (e) { if (e.which === 13 || e.which === 32) { self.close(); return false; } }) .end() .find('a') .click(function (e) { e.stopPropagation(); }) .end() .end(); /** * Opens the dialog. */ self.open = function (scrollbar) { if (scrollbar) { $dialog.css('height', '100%'); } setTimeout(function () { $dialog.addClass('h5p-open'); // Fade in // Triggering an event, in case something has to be done after dialog has been opened. H5P.jQuery(self).trigger('dialog-opened', [$dialog]); $dialog.focus(); }, 1); }; /** * Closes the dialog. */ self.close = function () { $dialog.removeClass('h5p-open'); // Fade out setTimeout(function () { $dialog.remove(); H5P.jQuery(self).trigger('dialog-closed', [$dialog]); $element.attr('tabindex', '-1'); $element.focus(); }, 200); }; }; /** * Gather copyright information for the given content. * * @param {Object} instance * H5P instance to get copyright information for. * @param {Object} parameters * Parameters of the content instance. * @param {number} contentId * Identifies the H5P content * @param {Object} metadata * Metadata of the content instance. * @returns {string} Copyright information. */ H5P.getCopyrights = function (instance, parameters, contentId, metadata) { var copyrights; if (instance.getCopyrights !== undefined) { try { // Use the instance's own copyright generator copyrights = instance.getCopyrights(); } catch (err) { // Failed, prevent crashing page. } } if (copyrights === undefined) { // Create a generic flat copyright list copyrights = new H5P.ContentCopyrights(); H5P.findCopyrights(copyrights, parameters, contentId); } var metadataCopyrights = H5P.buildMetadataCopyrights(metadata, instance.libraryInfo.machineName); if (metadataCopyrights !== undefined) { copyrights.addMediaInFront(metadataCopyrights); } if (copyrights !== undefined) { // Convert to string copyrights = copyrights.toString(); } return copyrights; }; /** * Gather a flat list of copyright information from the given parameters. * * @param {H5P.ContentCopyrights} info * Used to collect all information in. * @param {(Object|Array)} parameters * To search for file objects in. * @param {number} contentId * Used to insert thumbnails for images. * @param {Object} extras - Extras. * @param {object} extras.metadata - Metadata * @param {object} extras.machineName - Library name of some kind. * Metadata of the content instance. */ H5P.findCopyrights = function (info, parameters, contentId, extras) { // If extras are if (extras) { extras.params = parameters; buildFromMetadata(extras, extras.machineName, contentId); } var lastContentTypeName; // Cycle through parameters for (var field in parameters) { if (!parameters.hasOwnProperty(field)) { continue; // Do not check } /** * @deprecated This hack should be removed after 2017-11-01 * The code that was using this was removed by HFP-574 * This note was seen on 2018-04-04, and consultation with * higher authorities lead to keeping the code for now ;-) */ if (field === 'overrideSettings') { console.warn("The semantics field 'overrideSettings' is DEPRECATED and should not be used."); console.warn(parameters); continue; } var value = parameters[field]; if (value && value.library && typeof value.library === 'string') { lastContentTypeName = value.library.split(' ')[0]; } else if (value && value.library && typeof value.library === 'object') { lastContentTypeName = (value.library.library && typeof value.library.library === 'string') ? value.library.library.split(' ')[0] : lastContentTypeName; } if (value instanceof Array) { // Cycle through array H5P.findCopyrights(info, value, contentId); } else if (value instanceof Object) { buildFromMetadata(value, lastContentTypeName, contentId); // Check if object is a file with copyrights (old core) if (value.copyright === undefined || value.copyright.license === undefined || value.path === undefined || value.mime === undefined) { // Nope, cycle throught object H5P.findCopyrights(info, value, contentId); } else { // Found file, add copyrights var copyrights = new H5P.MediaCopyright(value.copyright); if (value.width !== undefined && value.height !== undefined) { copyrights.setThumbnail(new H5P.Thumbnail(H5P.getPath(value.path, contentId), value.width, value.height)); } info.addMedia(copyrights); } } } function buildFromMetadata(data, name, contentId) { if (data.metadata) { const metadataCopyrights = H5P.buildMetadataCopyrights(data.metadata, name); if (metadataCopyrights !== undefined) { if (data.params && data.params.contentName === 'Image' && data.params.file) { const path = data.params.file.path; const width = data.params.file.width; const height = data.params.file.height; metadataCopyrights.setThumbnail(new H5P.Thumbnail(H5P.getPath(path, contentId), width, height)); } info.addMedia(metadataCopyrights); } } } }; H5P.buildMetadataCopyrights = function (metadata) { if (metadata && metadata.license !== undefined && metadata.license !== 'U') { var dataset = { contentType: metadata.contentType, title: metadata.title, author: (metadata.authors && metadata.authors.length > 0) ? metadata.authors.map(function (author) { return (author.role) ? author.name + ' (' + author.role + ')' : author.name; }).join(', ') : undefined, source: metadata.source, year: (metadata.yearFrom) ? (metadata.yearFrom + ((metadata.yearTo) ? '-' + metadata.yearTo: '')) : undefined, license: metadata.license, version: metadata.licenseVersion, licenseExtras: metadata.licenseExtras, changes: (metadata.changes && metadata.changes.length > 0) ? metadata.changes.map(function (change) { return change.log + (change.author ? ', ' + change.author : '') + (change.date ? ', ' + change.date : ''); }).join(' / ') : undefined }; return new H5P.MediaCopyright(dataset); } }; /** * Display a dialog containing the download button and copy button. * * @param {H5P.jQuery} $element * @param {Object} contentData * @param {Object} library * @param {Object} instance * @param {number} contentId */ H5P.openReuseDialog = function ($element, contentData, library, instance, contentId) { let html = ''; if (contentData.displayOptions.export) { html += ''; } if (contentData.displayOptions.export && contentData.displayOptions.copy) { html += '
or
'; } if (contentData.displayOptions.copy) { html += ''; } const dialog = new H5P.Dialog('reuse', H5P.t('reuseContent'), html, $element); // Selecting embed code when dialog is opened H5P.jQuery(dialog).on('dialog-opened', function (e, $dialog) { H5P.jQuery('More Info').click(function (e) { e.stopPropagation(); }).appendTo($dialog.find('h2')); $dialog.find('.h5p-download-button').click(function () { window.location.href = contentData.exportUrl; instance.triggerXAPI('downloaded'); dialog.close(); }); $dialog.find('.h5p-copy-button').click(function () { const item = new H5P.ClipboardItem(library); item.contentId = contentId; H5P.setClipboard(item); instance.triggerXAPI('copied'); dialog.close(); H5P.attachToastTo( H5P.jQuery('.h5p-content:first')[0], H5P.t('contentCopied'), { position: { horizontal: 'centered', vertical: 'centered', noOverflowX: true } } ); }); H5P.trigger(instance, 'resize'); }).on('dialog-closed', function () { H5P.trigger(instance, 'resize'); }); dialog.open(); }; /** * Display a dialog containing the embed code. * * @param {H5P.jQuery} $element * Element to insert dialog after. * @param {string} embedCode * The embed code. * @param {string} resizeCode * The advanced resize code * @param {Object} size * The content's size. * @param {number} size.width * @param {number} size.height */ H5P.openEmbedDialog = function ($element, embedCode, resizeCode, size, instance) { var fullEmbedCode = embedCode + resizeCode; var dialog = new H5P.Dialog('embed', H5P.t('embed'), '' + H5P.t('size') + ': × px
' + H5P.t('showAdvanced') + '

' + H5P.t('advancedHelp') + '

', $element); // Selecting embed code when dialog is opened H5P.jQuery(dialog).on('dialog-opened', function (event, $dialog) { var $inner = $dialog.find('.h5p-inner'); var $scroll = $inner.find('.h5p-scroll-content'); var diff = $scroll.outerHeight() - $scroll.innerHeight(); var positionInner = function () { H5P.trigger(instance, 'resize'); }; // Handle changing of width/height var $w = $dialog.find('.h5p-embed-size:eq(0)'); var $h = $dialog.find('.h5p-embed-size:eq(1)'); var getNum = function ($e, d) { var num = parseFloat($e.val()); if (isNaN(num)) { return d; } return Math.ceil(num); }; var updateEmbed = function () { $dialog.find('.h5p-embed-code-container:first').val(fullEmbedCode.replace(':w', getNum($w, size.width)).replace(':h', getNum($h, size.height))); }; $w.change(updateEmbed); $h.change(updateEmbed); updateEmbed(); // Select text and expand textareas $dialog.find('.h5p-embed-code-container').each(function () { H5P.jQuery(this).css('height', this.scrollHeight + 'px').focus(function () { H5P.jQuery(this).select(); }); }); $dialog.find('.h5p-embed-code-container').eq(0).select(); positionInner(); // Expand advanced embed var expand = function () { var $expander = H5P.jQuery(this); var $content = $expander.next(); if ($content.is(':visible')) { $expander.removeClass('h5p-open').text(H5P.t('showAdvanced')).attr('aria-expanded', 'true'); $content.hide(); } else { $expander.addClass('h5p-open').text(H5P.t('hideAdvanced')).attr('aria-expanded', 'false'); $content.show(); } $dialog.find('.h5p-embed-code-container').each(function () { H5P.jQuery(this).css('height', this.scrollHeight + 'px'); }); positionInner(); }; $dialog.find('.h5p-expander').click(expand).keypress(function (event) { if (event.keyCode === 32) { expand.apply(this); return false; } }); }).on('dialog-closed', function () { H5P.trigger(instance, 'resize'); }); dialog.open(); }; /** * Show a toast message. * * The reference element could be dom elements the toast should be attached to, * or e.g. the document body for general toast messages. * * @param {DOM} element Reference element to show toast message for. * @param {string} message Message to show. * @param {object} [config] Configuration. * @param {string} [config.style=h5p-toast] Style name for the tooltip. * @param {number} [config.duration=3000] Toast message length in ms. * @param {object} [config.position] Relative positioning of the toast. * @param {string} [config.position.horizontal=centered] [before|left|centered|right|after]. * @param {string} [config.position.vertical=below] [above|top|centered|bottom|below]. * @param {number} [config.position.offsetHorizontal=0] Extra horizontal offset. * @param {number} [config.position.offsetVertical=0] Extra vetical offset. * @param {boolean} [config.position.noOverflowLeft=false] True to prevent overflow left. * @param {boolean} [config.position.noOverflowRight=false] True to prevent overflow right. * @param {boolean} [config.position.noOverflowTop=false] True to prevent overflow top. * @param {boolean} [config.position.noOverflowBottom=false] True to prevent overflow bottom. * @param {boolean} [config.position.noOverflowX=false] True to prevent overflow left and right. * @param {boolean} [config.position.noOverflowY=false] True to prevent overflow top and bottom. * @param {object} [config.position.overflowReference=document.body] DOM reference for overflow. */ H5P.attachToastTo = function (element, message, config) { if (element === undefined || message === undefined) { return; } const eventPath = function (evt) { var path = (evt.composedPath && evt.composedPath()) || evt.path; var target = evt.target; if (path != null) { // Safari doesn't include Window, but it should. return (path.indexOf(window) < 0) ? path.concat(window) : path; } if (target === window) { return [window]; } function getParents(node, memo) { memo = memo || []; var parentNode = node.parentNode; if (!parentNode) { return memo; } else { return getParents(parentNode, memo.concat(parentNode)); } } return [target].concat(getParents(target), window); }; /** * Handle click while toast is showing. */ const clickHandler = function (event) { /* * A common use case will be to attach toasts to buttons that are clicked. * The click would remove the toast message instantly without this check. * Children of the clicked element are also ignored. */ var path = eventPath(event); if (path.indexOf(element) !== -1) { return; } clearTimeout(timer); removeToast(); }; /** * Remove the toast message. */ const removeToast = function () { document.removeEventListener('click', clickHandler); if (toast.parentNode) { toast.parentNode.removeChild(toast); } }; /** * Get absolute coordinates for the toast. * * @param {DOM} element Reference element to show toast message for. * @param {DOM} toast Toast element. * @param {object} [position={}] Relative positioning of the toast message. * @param {string} [position.horizontal=centered] [before|left|centered|right|after]. * @param {string} [position.vertical=below] [above|top|centered|bottom|below]. * @param {number} [position.offsetHorizontal=0] Extra horizontal offset. * @param {number} [position.offsetVertical=0] Extra vetical offset. * @param {boolean} [position.noOverflowLeft=false] True to prevent overflow left. * @param {boolean} [position.noOverflowRight=false] True to prevent overflow right. * @param {boolean} [position.noOverflowTop=false] True to prevent overflow top. * @param {boolean} [position.noOverflowBottom=false] True to prevent overflow bottom. * @param {boolean} [position.noOverflowX=false] True to prevent overflow left and right. * @param {boolean} [position.noOverflowY=false] True to prevent overflow top and bottom. * @return {object} */ const getToastCoordinates = function (element, toast, position) { position = position || {}; position.offsetHorizontal = position.offsetHorizontal || 0; position.offsetVertical = position.offsetVertical || 0; const toastRect = toast.getBoundingClientRect(); const elementRect = element.getBoundingClientRect(); let left = 0; let top = 0; // Compute horizontal position switch (position.horizontal) { case 'before': left = elementRect.left - toastRect.width - position.offsetHorizontal; break; case 'after': left = elementRect.left + elementRect.width + position.offsetHorizontal; break; case 'left': left = elementRect.left + position.offsetHorizontal; break; case 'right': left = elementRect.left + elementRect.width - toastRect.width - position.offsetHorizontal; break; case 'centered': left = elementRect.left + elementRect.width / 2 - toastRect.width / 2 + position.offsetHorizontal; break; default: left = elementRect.left + elementRect.width / 2 - toastRect.width / 2 + position.offsetHorizontal; } // Compute vertical position switch (position.vertical) { case 'above': top = elementRect.top - toastRect.height - position.offsetVertical; break; case 'below': top = elementRect.top + elementRect.height + position.offsetVertical; break; case 'top': top = elementRect.top + position.offsetVertical; break; case 'bottom': top = elementRect.top + elementRect.height - toastRect.height - position.offsetVertical; break; case 'centered': top = elementRect.top + elementRect.height / 2 - toastRect.height / 2 + position.offsetVertical; break; default: top = elementRect.top + elementRect.height + position.offsetVertical; } // Prevent overflow const overflowElement = document.body; const bounds = overflowElement.getBoundingClientRect(); if ((position.noOverflowLeft || position.noOverflowX) && (left < bounds.x)) { left = bounds.x; } if ((position.noOverflowRight || position.noOverflowX) && ((left + toastRect.width) > (bounds.x + bounds.width))) { left = bounds.x + bounds.width - toastRect.width; } if ((position.noOverflowTop || position.noOverflowY) && (top < bounds.y)) { top = bounds.y; } if ((position.noOverflowBottom || position.noOverflowY) && ((top + toastRect.height) > (bounds.y + bounds.height))) { left = bounds.y + bounds.height - toastRect.height; } return {left: left, top: top}; }; // Sanitization config = config || {}; config.style = config.style || 'h5p-toast'; config.duration = config.duration || 3000; // Build toast const toast = document.createElement('div'); toast.setAttribute('id', config.style); toast.classList.add('h5p-toast-disabled'); toast.classList.add(config.style); const msg = document.createElement('span'); msg.innerHTML = message; toast.appendChild(msg); document.body.appendChild(toast); // The message has to be set before getting the coordinates const coordinates = getToastCoordinates(element, toast, config.position); toast.style.left = Math.round(coordinates.left) + 'px'; toast.style.top = Math.round(coordinates.top) + 'px'; toast.classList.remove('h5p-toast-disabled'); const timer = setTimeout(removeToast, config.duration); // The toast can also be removed by clicking somewhere document.addEventListener('click', clickHandler); }; /** * Copyrights for a H5P Content Library. * * @class */ H5P.ContentCopyrights = function () { var label; var media = []; var content = []; /** * Set label. * * @param {string} newLabel */ this.setLabel = function (newLabel) { label = newLabel; }; /** * Add sub content. * * @param {H5P.MediaCopyright} newMedia */ this.addMedia = function (newMedia) { if (newMedia !== undefined) { media.push(newMedia); } }; /** * Add sub content in front. * * @param {H5P.MediaCopyright} newMedia */ this.addMediaInFront = function (newMedia) { if (newMedia !== undefined) { media.unshift(newMedia); } }; /** * Add sub content. * * @param {H5P.ContentCopyrights} newContent */ this.addContent = function (newContent) { if (newContent !== undefined) { content.push(newContent); } }; /** * Print content copyright. * * @returns {string} HTML. */ this.toString = function () { var html = ''; // Add media rights for (var i = 0; i < media.length; i++) { html += media[i]; } // Add sub content rights for (i = 0; i < content.length; i++) { html += content[i]; } if (html !== '') { // Add a label to this info if (label !== undefined) { html = '

' + label + '

' + html; } // Add wrapper html = '
' + html + '
'; } return html; }; }; /** * A ordered list of copyright fields for media. * * @class * @param {Object} copyright * Copyright information fields. * @param {Object} [labels] * Translation of labels. * @param {Array} [order] * Order of the fields. * @param {Object} [extraFields] * Add extra copyright fields. */ H5P.MediaCopyright = function (copyright, labels, order, extraFields) { var thumbnail; var list = new H5P.DefinitionList(); /** * Get translated label for field. * * @private * @param {string} fieldName * @returns {string} */ var getLabel = function (fieldName) { if (labels === undefined || labels[fieldName] === undefined) { return H5P.t(fieldName); } return labels[fieldName]; }; /** * Get humanized value for the license field. * * @private * @param {string} license * @param {string} [version] * @returns {string} */ var humanizeLicense = function (license, version) { var copyrightLicense = H5P.copyrightLicenses[license]; // Build license string var value = ''; if (!(license === 'PD' && version)) { // Add license label value += (copyrightLicense.hasOwnProperty('label') ? copyrightLicense.label : copyrightLicense); } // Check for version info var versionInfo; if (copyrightLicense.versions) { if (copyrightLicense.versions.default && (!version || !copyrightLicense.versions[version])) { version = copyrightLicense.versions.default; } if (version && copyrightLicense.versions[version]) { versionInfo = copyrightLicense.versions[version]; } } if (versionInfo) { // Add license version if (value) { value += ' '; } value += (versionInfo.hasOwnProperty('label') ? versionInfo.label : versionInfo); } // Add link if specified var link; if (copyrightLicense.hasOwnProperty('link')) { link = copyrightLicense.link.replace(':version', copyrightLicense.linkVersions ? copyrightLicense.linkVersions[version] : version); } else if (versionInfo && copyrightLicense.hasOwnProperty('link')) { link = versionInfo.link; } if (link) { value = '' + value + ''; } // Generate parenthesis var parenthesis = ''; if (license !== 'PD' && license !== 'C') { parenthesis += license; } if (version && version !== 'CC0 1.0') { if (parenthesis && license !== 'GNU GPL') { parenthesis += ' '; } parenthesis += version; } if (parenthesis) { value += ' (' + parenthesis + ')'; } if (license === 'C') { value += ' ©'; } return value; }; if (copyright !== undefined) { // Add the extra fields for (var field in extraFields) { if (extraFields.hasOwnProperty(field)) { copyright[field] = extraFields[field]; } } if (order === undefined) { // Set default order order = ['contentType', 'title', 'license', 'author', 'year', 'source', 'licenseExtras', 'changes']; } for (var i = 0; i < order.length; i++) { var fieldName = order[i]; if (copyright[fieldName] !== undefined && copyright[fieldName] !== '') { var humanValue = copyright[fieldName]; if (fieldName === 'license') { humanValue = humanizeLicense(copyright.license, copyright.version); } if (fieldName === 'source') { humanValue = (humanValue) ? '' + humanValue + '' : undefined; } list.add(new H5P.Field(getLabel(fieldName), humanValue)); } } } /** * Set thumbnail. * * @param {H5P.Thumbnail} newThumbnail */ this.setThumbnail = function (newThumbnail) { thumbnail = newThumbnail; }; /** * Checks if this copyright is undisclosed. * I.e. only has the license attribute set, and it's undisclosed. * * @returns {boolean} */ this.undisclosed = function () { if (list.size() === 1) { var field = list.get(0); if (field.getLabel() === getLabel('license') && field.getValue() === humanizeLicense('U')) { return true; } } return false; }; /** * Print media copyright. * * @returns {string} HTML. */ this.toString = function () { var html = ''; if (this.undisclosed()) { return html; // No need to print a copyright with a single undisclosed license. } if (thumbnail !== undefined) { html += thumbnail; } html += list; if (html !== '') { html = ''; } return html; }; }; /** * A simple and elegant class for creating thumbnails of images. * * @class * @param {string} source * @param {number} width * @param {number} height */ H5P.Thumbnail = function (source, width, height) { var thumbWidth, thumbHeight = 100; if (width !== undefined) { thumbWidth = Math.round(thumbHeight * (width / height)); } /** * Print thumbnail. * * @returns {string} HTML. */ this.toString = function () { return '' + H5P.t('thumbnail') + ''; }; }; /** * Simple data structure class for storing a single field. * * @class * @param {string} label * @param {string} value */ H5P.Field = function (label, value) { /** * Public. Get field label. * * @returns {String} */ this.getLabel = function () { return label; }; /** * Public. Get field value. * * @returns {String} */ this.getValue = function () { return value; }; }; /** * Simple class for creating a definition list. * * @class */ H5P.DefinitionList = function () { var fields = []; /** * Add field to list. * * @param {H5P.Field} field */ this.add = function (field) { fields.push(field); }; /** * Get Number of fields. * * @returns {number} */ this.size = function () { return fields.length; }; /** * Get field at given index. * * @param {number} index * @returns {H5P.Field} */ this.get = function (index) { return fields[index]; }; /** * Print definition list. * * @returns {string} HTML. */ this.toString = function () { var html = ''; for (var i = 0; i < fields.length; i++) { var field = fields[i]; html += '
' + field.getLabel() + '
' + field.getValue() + '
'; } return (html === '' ? html : '
' + html + '
'); }; }; /** * THIS FUNCTION/CLASS IS DEPRECATED AND WILL BE REMOVED. * * Helper object for keeping coordinates in the same format all over. * * @deprecated * Will be removed march 2016. * @class * @param {number} x * @param {number} y * @param {number} w * @param {number} h */ H5P.Coords = function (x, y, w, h) { if ( !(this instanceof H5P.Coords) ) return new H5P.Coords(x, y, w, h); /** @member {number} */ this.x = 0; /** @member {number} */ this.y = 0; /** @member {number} */ this.w = 1; /** @member {number} */ this.h = 1; if (typeof(x) === 'object') { this.x = x.x; this.y = x.y; this.w = x.w; this.h = x.h; } else { if (x !== undefined) { this.x = x; } if (y !== undefined) { this.y = y; } if (w !== undefined) { this.w = w; } if (h !== undefined) { this.h = h; } } return this; }; /** * Parse library string into values. * * @param {string} library * library in the format "machineName majorVersion.minorVersion" * @returns {Object} * library as an object with machineName, majorVersion and minorVersion properties * return false if the library parameter is invalid */ H5P.libraryFromString = function (library) { var regExp = /(.+)\s(\d+)\.(\d+)$/g; var res = regExp.exec(library); if (res !== null) { return { 'machineName': res[1], 'majorVersion': parseInt(res[2]), 'minorVersion': parseInt(res[3]) }; } else { return false; } }; /** * Get the path to the library * * @param {string} library * The library identifier in the format "machineName-majorVersion.minorVersion". * @returns {string} * The full path to the library. */ H5P.getLibraryPath = function (library) { if (H5PIntegration.urlLibraries !== undefined) { // This is an override for those implementations that has a different libraries URL, e.g. Moodle return H5PIntegration.urlLibraries + '/' + library; } else { return H5PIntegration.url + '/libraries/' + library; } }; /** * Recursivly clone the given object. * * @param {Object|Array} object * Object to clone. * @param {boolean} [recursive] * @returns {Object|Array} * A clone of object. */ H5P.cloneObject = function (object, recursive) { // TODO: Consider if this needs to be in core. Doesn't $.extend do the same? var clone = object instanceof Array ? [] : {}; for (var i in object) { if (object.hasOwnProperty(i)) { if (recursive !== undefined && recursive && typeof object[i] === 'object') { clone[i] = H5P.cloneObject(object[i], recursive); } else { clone[i] = object[i]; } } } return clone; }; /** * Remove all empty spaces before and after the value. * * @param {string} value * @returns {string} */ H5P.trim = function (value) { return value.replace(/^\s+|\s+$/g, ''); // TODO: Only include this or String.trim(). What is best? // I'm leaning towards implementing the missing ones: http://kangax.github.io/compat-table/es5/ // So should we make this function deprecated? }; /** * Check if JavaScript path/key is loaded. * * @param {string} path * @returns {boolean} */ H5P.jsLoaded = function (path) { H5PIntegration.loadedJs = H5PIntegration.loadedJs || []; return H5P.jQuery.inArray(path, H5PIntegration.loadedJs) !== -1; }; /** * Check if styles path/key is loaded. * * @param {string} path * @returns {boolean} */ H5P.cssLoaded = function (path) { H5PIntegration.loadedCss = H5PIntegration.loadedCss || []; return H5P.jQuery.inArray(path, H5PIntegration.loadedCss) !== -1; }; /** * Shuffle an array in place. * * @param {Array} array * Array to shuffle * @returns {Array} * The passed array is returned for chaining. */ H5P.shuffleArray = function (array) { // TODO: Consider if this should be a part of core. I'm guessing very few libraries are going to use it. if (!(array instanceof Array)) { return; } var i = array.length, j, tempi, tempj; if ( i === 0 ) return false; while ( --i ) { j = Math.floor( Math.random() * ( i + 1 ) ); tempi = array[i]; tempj = array[j]; array[i] = tempj; array[j] = tempi; } return array; }; /** * Post finished results for user. * * @deprecated * Do not use this function directly, trigger the finish event instead. * Will be removed march 2016 * @param {number} contentId * Identifies the content * @param {number} score * Achieved score/points * @param {number} maxScore * The maximum score/points that can be achieved * @param {number} [time] * Reported time consumption/usage */ H5P.setFinished = function (contentId, score, maxScore, time) { var validScore = typeof score === 'number' || score instanceof Number; if (validScore && H5PIntegration.postUserStatistics === true) { /** * Return unix timestamp for the given JS Date. * * @private * @param {Date} date * @returns {Number} */ var toUnix = function (date) { return Math.round(date.getTime() / 1000); }; // Post the results const data = { contentId: contentId, score: score, maxScore: maxScore, opened: toUnix(H5P.opened[contentId]), finished: toUnix(new Date()), time: time }; H5P.jQuery.post(H5PIntegration.ajax.setFinished, data) .fail(function () { H5P.offlineRequestQueue.add(H5PIntegration.ajax.setFinished, data); }); } }; // Add indexOf to browsers that lack them. (IEs) if (!Array.prototype.indexOf) { Array.prototype.indexOf = function (needle) { for (var i = 0; i < this.length; i++) { if (this[i] === needle) { return i; } } return -1; }; } // Need to define trim() since this is not available on older IEs, // and trim is used in several libs if (String.prototype.trim === undefined) { String.prototype.trim = function () { return H5P.trim(this); }; } /** * Trigger an event on an instance * * Helper function that triggers an event if the instance supports event handling * * @param {Object} instance * Instance of H5P content * @param {string} eventType * Type of event to trigger * @param {*} data * @param {Object} extras */ H5P.trigger = function (instance, eventType, data, extras) { // Try new event system first if (instance.trigger !== undefined) { instance.trigger(eventType, data, extras); } // Try deprecated event system else if (instance.$ !== undefined && instance.$.trigger !== undefined) { instance.$.trigger(eventType); } }; /** * Register an event handler * * Helper function that registers an event handler for an event type if * the instance supports event handling * * @param {Object} instance * Instance of H5P content * @param {string} eventType * Type of event to listen for * @param {H5P.EventCallback} handler * Callback that gets triggered for events of the specified type */ H5P.on = function (instance, eventType, handler) { // Try new event system first if (instance.on !== undefined) { instance.on(eventType, handler); } // Try deprecated event system else if (instance.$ !== undefined && instance.$.on !== undefined) { instance.$.on(eventType, handler); } }; /** * Generate random UUID * * @returns {string} UUID */ H5P.createUUID = function () { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (char) { var random = Math.random()*16|0, newChar = char === 'x' ? random : (random&0x3|0x8); return newChar.toString(16); }); }; /** * Create title * * @param {string} rawTitle * @param {number} maxLength * @returns {string} */ H5P.createTitle = function (rawTitle, maxLength) { if (!rawTitle) { return ''; } if (maxLength === undefined) { maxLength = 60; } var title = H5P.jQuery('
') .text( // Strip tags rawTitle.replace(/(<([^>]+)>)/ig,"") // Escape ).text(); if (title.length > maxLength) { title = title.substr(0, maxLength - 3) + '...'; } return title; }; // Wrap in privates (function ($) { /** * Creates ajax requests for inserting, updateing and deleteing * content user data. * * @private * @param {number} contentId What content to store the data for. * @param {string} dataType Identifies the set of data for this content. * @param {string} subContentId Identifies sub content * @param {function} [done] Callback when ajax is done. * @param {object} [data] To be stored for future use. * @param {boolean} [preload=false] Data is loaded when content is loaded. * @param {boolean} [invalidate=false] Data is invalidated when content changes. * @param {boolean} [async=true] */ function contentUserDataAjax(contentId, dataType, subContentId, done, data, preload, invalidate, async) { if (H5PIntegration.user === undefined) { // Not logged in, no use in saving. done('Not signed in.'); // Return value used when storing state in localStorage return; } var options = { url: H5PIntegration.ajax.contentUserData.replace(':contentId', contentId).replace(':dataType', dataType).replace(':subContentId', subContentId ? subContentId : 0), dataType: 'json', async: async === undefined ? true : async }; if (data !== undefined) { options.type = 'POST'; options.data = { data: (data === null ? 0 : data), preload: (preload ? 1 : 0), invalidate: (invalidate ? 1 : 0) }; } else { options.type = 'GET'; } if (done !== undefined) { options.error = function (xhr, error) { done(error); }; options.success = function (response) { if (!response.success) { done(response.message); return; } if (response.data === false || response.data === undefined) { done(); return; } done(undefined, response.data); }; } $.ajax(options); } /** * Get user data for given content. * * @param {number} contentId * What content to get data for. * @param {string} dataId * Identifies the set of data for this content. * @param {function} done * Callback with error and data parameters. * @param {string} [subContentId] * Identifies which data belongs to sub content. */ H5P.getUserData = function (contentId, dataId, done, subContentId) { if (!subContentId) { subContentId = 0; // Default } H5PIntegration.contents = H5PIntegration.contents || {}; var content = H5PIntegration.contents['cid-' + contentId] || {}; var preloadedData = content.contentUserData; /* * If previous state in DB is empty (user might not be logged in), * alternatively try to preload state from localStorage */ if (preloadedData && preloadedData[subContentId] && preloadedData[subContentId][dataId] === '{}') { if (H5PIntegration.saveContentStorages && H5PIntegration.saveContentStorages.localStorage && H5P.localStorageSupported) { const localStorageData = window.localStorage.getItem('H5P-cid-' + contentId + '-sid-' + subContentId); if (localStorageData) { let data = {}; try { data = JSON.parse(localStorageData); } catch (err) { console.error('Unable to parse JSON from state in localStorage.', err); } if (data.state && data.checksum) { // Detect whether content parameters changed meanwhile if (data.checksum === H5P.getNumericalHash(content.jsonContent)) { try { data = JSON.stringify(data.state); preloadedData[subContentId][dataId] = data; } catch (err) { console.error('Unable to stringify JSON for state in localStorage.', err); } } else { // Content has been changed preloadedData[subContentId][dataId] = 'RESET'; } } } } } if (preloadedData && preloadedData[subContentId] && preloadedData[subContentId][dataId] !== undefined) { if (preloadedData[subContentId][dataId] === 'RESET') { done(undefined, null); return; } try { done(undefined, JSON.parse(preloadedData[subContentId][dataId])); } catch (err) { done(err); } } else { contentUserDataAjax(contentId, dataId, subContentId, function (err, data) { if (err || data === undefined) { done(err, data); return; // Error or no data } // Cache in preloaded if (content.contentUserData === undefined) { content.contentUserData = preloadedData = {}; } if (preloadedData[subContentId] === undefined) { preloadedData[subContentId] = {}; } preloadedData[subContentId][dataId] = data; // Done. Try to decode JSON try { done(undefined, JSON.parse(data)); } catch (e) { done(e); } }); } }; /** * Async error handling. * * @callback H5P.ErrorCallback * @param {*} error */ /** * Set user data for given content. * * @param {number} contentId * What content to get data for. * @param {string} dataId * Identifies the set of data for this content. * @param {Object} data * The data that is to be stored. * @param {Object} [extras] * Extra properties * @param {string} [extras.subContentId] * Identifies which data belongs to sub content. * @param {boolean} [extras.preloaded=true] * If the data should be loaded when content is loaded. * @param {boolean} [extras.deleteOnChange=false] * If the data should be invalidated when the content changes. * @param {H5P.ErrorCallback} [extras.errorCallback] * Callback with error as parameters. * @param {boolean} [extras.async=true] */ H5P.setUserData = function (contentId, dataId, data, extras) { var options = H5P.jQuery.extend(true, {}, { subContentId: 0, preloaded: true, deleteOnChange: false, async: true }, extras); try { data = JSON.stringify(data); } catch (err) { if (options.errorCallback) { options.errorCallback(err); } return; // Failed to serialize. } var content = H5PIntegration.contents['cid-' + contentId]; if (content === undefined) { content = H5PIntegration.contents['cid-' + contentId] = {}; } if (!content.contentUserData) { content.contentUserData = {}; } var preloadedData = content.contentUserData; if (preloadedData[options.subContentId] === undefined) { preloadedData[options.subContentId] = {}; } if (data === preloadedData[options.subContentId][dataId]) { return; // No need to save this twice. } preloadedData[options.subContentId][dataId] = data; contentUserDataAjax(contentId, dataId, options.subContentId, function (error) { if (options.errorCallback && error) { options.errorCallback(error); } // Additionally store state in localStorage if requested if ((!error || error === 'Not signed in.') && H5PIntegration.saveContentStorages && H5PIntegration.saveContentStorages.localStorage && H5P.localStorageSupported ) { // Add checksum of params to detect changes for resetting localStorage window.localStorage.setItem( 'H5P-cid-' + contentId + '-sid-' + options.subContentId, '{"checksum":' + H5P.getNumericalHash(content.jsonContent) + ',"state":' + data + '}' ); } }, data, options.preloaded, options.deleteOnChange, options.async); }; /** * Delete user data for given content. * * @param {number} contentId * What content to remove data for. * @param {string} dataId * Identifies the set of data for this content. * @param {string} [subContentId] * Identifies which data belongs to sub content. */ H5P.deleteUserData = function (contentId, dataId, subContentId) { if (!subContentId) { subContentId = 0; // Default } // Remove from preloaded/cache var preloadedData = H5PIntegration.contents['cid-' + contentId].contentUserData; if (preloadedData && preloadedData[subContentId] && preloadedData[subContentId][dataId]) { delete preloadedData[subContentId][dataId]; } contentUserDataAjax(contentId, dataId, subContentId, function (error) { // When done deleting user data in DB, delete in localStorage if ((!error || error === 'Not signed in.') && H5P.localStorageSupported) { window.localStorage.removeItem('H5P-cid-' + contentId + '-sid-' + subContentId); } }, null); }; /** * Function for getting content for a certain ID * * @param {number} contentId * @return {Object} */ H5P.getContentForInstance = function (contentId) { var key = 'cid-' + contentId; var exists = H5PIntegration && H5PIntegration.contents && H5PIntegration.contents[key]; return exists ? H5PIntegration.contents[key] : undefined; }; /** * Prepares the content parameters for storing in the clipboard. * * @class * @param {Object} parameters The parameters for the content to store * @param {string} [genericProperty] If only part of the parameters are generic, which part * @param {string} [specificKey] If the parameters are specific, what content type does it fit * @returns {Object} Ready for the clipboard */ H5P.ClipboardItem = function (parameters, genericProperty, specificKey) { var self = this; /** * Set relative dimensions when params contains a file with a width and a height. * Very useful to be compatible with wysiwyg editors. * * @private */ var setDimensionsFromFile = function () { if (!self.generic) { return; } var params = self.specific[self.generic]; if (!params.params.file || !params.params.file.width || !params.params.file.height) { return; } self.width = 20; // % self.height = (params.params.file.height / params.params.file.width) * self.width; }; if (!genericProperty) { genericProperty = 'action'; parameters = { action: parameters }; } self.specific = parameters; if (genericProperty && parameters[genericProperty]) { self.generic = genericProperty; } if (specificKey) { self.from = specificKey; } if (window.H5PEditor && H5PEditor.contentId) { self.contentId = H5PEditor.contentId; } if (!self.specific.width && !self.specific.height) { setDimensionsFromFile(); } }; /** * Store item in the H5P Clipboard. * * @param {H5P.ClipboardItem|*} clipboardItem */ H5P.clipboardify = function (clipboardItem) { if (!(clipboardItem instanceof H5P.ClipboardItem)) { clipboardItem = new H5P.ClipboardItem(clipboardItem); } H5P.setClipboard(clipboardItem); }; /** * Retrieve parsed clipboard data. * * @return {Object} */ H5P.getClipboard = function () { return parseClipboard(); }; /** * Set item in the H5P Clipboard. * * @param {H5P.ClipboardItem|object} clipboardItem - Data to be set. */ H5P.setClipboard = function (clipboardItem) { localStorage.setItem('h5pClipboard', JSON.stringify(clipboardItem)); // Trigger an event so all 'Paste' buttons may be enabled. H5P.externalDispatcher.trigger('datainclipboard', {reset: false}); }; /** * Get numerical hash for a text. * * @param {string} text - Text to be hashed. */ H5P.getNumericalHash = function (text) { text = text || ''; return text .split('') .reduce(function (result, current) { return (((result << 5) - result) + current.charCodeAt(0)) | 0; }, 0); }; /** * Get config for a library * * @param string machineName * @return Object */ H5P.getLibraryConfig = function (machineName) { var hasConfig = H5PIntegration.libraryConfig && H5PIntegration.libraryConfig[machineName]; return hasConfig ? H5PIntegration.libraryConfig[machineName] : {}; }; /** * Get item from the H5P Clipboard. * * @private * @return {Object} */ var parseClipboard = function () { var clipboardData = localStorage.getItem('h5pClipboard'); if (!clipboardData) { return; } // Try to parse clipboard dat try { clipboardData = JSON.parse(clipboardData); } catch (err) { console.error('Unable to parse JSON from clipboard.', err); return; } // Update file URLs and reset content Ids recursiveUpdate(clipboardData.specific, function (path) { var isTmpFile = (path.substr(-4, 4) === '#tmp'); if (!isTmpFile && clipboardData.contentId && !path.match(/^https?:\/\//i)) { // Comes from existing content if (H5PEditor.contentId) { // .. to existing content return '../' + clipboardData.contentId + '/' + path; } else { // .. to new content return (H5PEditor.contentRelUrl ? H5PEditor.contentRelUrl : '../content/') + clipboardData.contentId + '/' + path; } } return path; // Will automatically be looked for in tmp folder }); if (clipboardData.generic) { // Use reference instead of key clipboardData.generic = clipboardData.specific[clipboardData.generic]; } return clipboardData; }; /** * Update file URLs and reset content IDs. * Useful when copying content. * * @private * @param {object} params Reference * @param {function} handler Modifies the path to work when pasted */ var recursiveUpdate = function (params, handler) { for (var prop in params) { if (params.hasOwnProperty(prop) && params[prop] instanceof Object) { var obj = params[prop]; if (obj.path !== undefined && obj.mime !== undefined) { obj.path = handler(obj.path); } else { if (obj.library !== undefined && obj.subContentId !== undefined) { // Avoid multiple content with same ID delete obj.subContentId; } recursiveUpdate(obj, handler); } } } }; // Init H5P when page is fully loadded $(document).ready(function () { window.addEventListener('storage', function (event) { // Pick up clipboard changes from other tabs if (event.key === 'h5pClipboard') { // Trigger an event so all 'Paste' buttons may be enabled. H5P.externalDispatcher.trigger('datainclipboard', {reset: event.newValue === null}); } }); var ccVersions = { 'default': '4.0', '4.0': H5P.t('licenseCC40'), '3.0': H5P.t('licenseCC30'), '2.5': H5P.t('licenseCC25'), '2.0': H5P.t('licenseCC20'), '1.0': H5P.t('licenseCC10'), }; /** * Maps copyright license codes to their human readable counterpart. * * @type {Object} */ H5P.copyrightLicenses = { 'U': H5P.t('licenseU'), 'CC BY': { label: H5P.t('licenseCCBY'), link: 'http://creativecommons.org/licenses/by/:version', versions: ccVersions }, 'CC BY-SA': { label: H5P.t('licenseCCBYSA'), link: 'http://creativecommons.org/licenses/by-sa/:version', versions: ccVersions }, 'CC BY-ND': { label: H5P.t('licenseCCBYND'), link: 'http://creativecommons.org/licenses/by-nd/:version', versions: ccVersions }, 'CC BY-NC': { label: H5P.t('licenseCCBYNC'), link: 'http://creativecommons.org/licenses/by-nc/:version', versions: ccVersions }, 'CC BY-NC-SA': { label: H5P.t('licenseCCBYNCSA'), link: 'http://creativecommons.org/licenses/by-nc-sa/:version', versions: ccVersions }, 'CC BY-NC-ND': { label: H5P.t('licenseCCBYNCND'), link: 'http://creativecommons.org/licenses/by-nc-nd/:version', versions: ccVersions }, 'CC0 1.0': { label: H5P.t('licenseCC010'), link: 'https://creativecommons.org/publicdomain/zero/1.0/' }, 'GNU GPL': { label: H5P.t('licenseGPL'), link: 'http://www.gnu.org/licenses/gpl-:version-standalone.html', linkVersions: { 'v3': '3.0', 'v2': '2.0', 'v1': '1.0' }, versions: { 'default': 'v3', 'v3': H5P.t('licenseV3'), 'v2': H5P.t('licenseV2'), 'v1': H5P.t('licenseV1') } }, 'PD': { label: H5P.t('licensePD'), versions: { 'CC0 1.0': { label: H5P.t('licenseCC010'), link: 'https://creativecommons.org/publicdomain/zero/1.0/' }, 'CC PDM': { label: H5P.t('licensePDM'), link: 'https://creativecommons.org/publicdomain/mark/1.0/' } } }, 'ODC PDDL': 'Public Domain Dedication and Licence', 'CC PDM': { label: H5P.t('licensePDM'), link: 'https://creativecommons.org/publicdomain/mark/1.0/' }, 'C': H5P.t('licenseC'), }; /** * Indicates if H5P is embedded on an external page using iframe. * @member {boolean} H5P.externalEmbed */ // Relay events to top window. This must be done before H5P.init // since events may be fired on initialization. if (H5P.isFramed && H5P.externalEmbed === false) { H5P.externalDispatcher.on('*', function (event) { window.parent.H5P.externalDispatcher.trigger.call(this, event); }); } /** * Prevent H5P Core from initializing. Must be overriden before document ready. * @member {boolean} H5P.preventInit */ if (!H5P.preventInit) { // Note that this start script has to be an external resource for it to // load in correct order in IE9. H5P.init(document.body); } if (H5PIntegration.saveFreq !== false) { // When was the last state stored var lastStoredOn = 0; // Store the current state of the H5P when leaving the page. var storeCurrentState = function () { // Make sure at least 250 ms has passed since last save var currentTime = new Date().getTime(); if (currentTime - lastStoredOn > 250) { lastStoredOn = currentTime; for (var i = 0; i < H5P.instances.length; i++) { var instance = H5P.instances[i]; if (instance.getCurrentState instanceof Function || typeof instance.getCurrentState === 'function') { var state = instance.getCurrentState(); if (state !== undefined) { // Async is not used to prevent the request from being cancelled. H5P.setUserData(instance.contentId, 'state', state, {deleteOnChange: true, async: false}); } } } } }; // iPad does not support beforeunload, therefore using unload H5P.$window.one('beforeunload unload', function () { // Only want to do this once H5P.$window.off('pagehide beforeunload unload'); storeCurrentState(); }); // pagehide is used on iPad when tabs are switched H5P.$window.on('pagehide', storeCurrentState); } }); })(H5P.jQuery);