░▒▓████████████████████████████████▓▒░ ░▒▓█ ▓▒░ ░▒▓█ ~ S W A M P ~ ▓▒░ ░▒▓█ ▓▒░ ░▒▓████████████████████████████████▓▒░
pplmvsvm
LOCATION:
/datadrive/moodledata/filedir/69/43
☗ ROOT
↻ REFRESH
✎ CARVE FLESH
EDITING: 6943ef68f947b345cd45dd03084d8bdd7e8fc716
import LibraryScreenOverlay from './libraryScreenOverlay.js'; import { addResizeListener } from 'detect-resize'; import '@styles/libraryScreen.scss'; import '@styles/branchingQuestion.scss'; export default class LibraryScreen extends H5P.EventDispatcher { /** * LibraryScreen. * @class * @param {BranchingScenario} parent BranchingScenario object. * @param {string} courseTitle Title. * @param {object} library H5P Library Data. * @param {object} [previousState] Previous states of library. * @param {number} [lastNodeId] Id of last node from previous state. */ constructor(parent, courseTitle, library, previousState, lastNodeId = 0) { super(); this.parent = parent; this.previousState = previousState ?? {}; this.lastNodeId = lastNodeId; this.currentLibraryElement; this.currentLibraryInstance; this.currentLibraryId = 0; this.nextLibraryId = library.nextContentId; this.libraryFeedback = library.feedback; this.nextLibraries = {}; this.libraryInstances = {}; this.libraryFinishingRequirements = []; this.libraryTitle; this.branchingQuestions = []; this.navButton; this.header; this.isShowing = false; this.contentOverlays = []; const contentTitle = (library.type && library.type.metadata && library.type.metadata.title ? library.type.metadata.title : ''); this.wrapper = this.createWrapper(courseTitle, (contentTitle ? contentTitle : 'Untitled Content'), library.showContentTitle && contentTitle); this.wrapper.classList.add('h5p-next-screen'); this.wrapper.classList.add('h5p-branching-hidden'); const libraryWrapper = this.createLibraryElement(library, false); this.currentLibraryWrapper = libraryWrapper; this.currentLibraryElement = libraryWrapper.getElementsByClassName('h5p-branching-scenario-content')[0]; this.currentLibraryInstance = this.libraryInstances[0]; // TODO: Decide whether the start screen id should be hardcoded as 0 this.createNextLibraries(library); this.wrapper.appendChild(libraryWrapper); this.createNavButtons(); /** * Handle enterfullscreen event and resize the library instance */ this.parent.on('enterFullScreen', () => { setTimeout(() => { if (this.currentLibraryInstance) { this.currentLibraryInstance.trigger('resize'); } }, 500); }); } /** * Resize wrapper to fit library */ handleLibraryResize() { // Fullscreen always use the full height available to it if (this.parent.isFullScreen()) { this.currentLibraryWrapper.style.height = ''; this.wrapper.style.minHeight = ''; return; } this.currentLibraryWrapper.style.height = `${this.currentLibraryElement.clientHeight + 40}px`; // NOTE: This is a brittle hardcoding of the header height this.wrapper.style.minHeight = `${this.currentLibraryElement.clientHeight + 40 + 70.17}px`; if ( this.currentLibraryWrapper.offsetHeight < this.currentLibraryElement.scrollHeight ) { this.currentLibraryElement.tabIndex = 0; } } /** * Create wrapping div for library screen. * @param {string} courseTitle Main title. * @param {string} libraryTitle Library specific title. * @returns {HTMLElement} Wrapping div. */ createWrapper(courseTitle, libraryTitle, showLibraryTitle) { const wrapper = document.createElement('div'); const titleDiv = document.createElement('div'); titleDiv.classList.add('h5p-title-wrapper'); if (H5P.fullscreenSupported) { const fullScreenButton = document.createElement('button'); fullScreenButton.className = 'h5p-branching-full-screen'; fullScreenButton.setAttribute( 'aria-label', this.parent.params.l10n.fullscreenAria ); fullScreenButton.addEventListener('click', () => { this.trigger('toggleFullScreen'); }); titleDiv.appendChild(fullScreenButton); } const headers = document.createElement('div'); headers.className = 'h5p-branching-header'; const headerTitle = document.createElement('h1'); headerTitle.innerHTML = courseTitle; headers.appendChild(headerTitle); const headerSubtitle = document.createElement('h2'); headerSubtitle.classList.add('library-subtitle'); headerSubtitle.innerHTML = showLibraryTitle ? libraryTitle : ' '; headerSubtitle.setAttribute('tabindex', '-1'); headerSubtitle.setAttribute('aria-label', libraryTitle); headers.appendChild(headerSubtitle); titleDiv.appendChild(headers); this.libraryTitle = headerSubtitle; const header = document.createElement('div'); header.classList.add('h5p-screen-header'); this.header = header; header.appendChild(titleDiv); wrapper.appendChild(header); const handleWrapperResize = () => { if (this.wrapper.clientHeight > 500) { this.wrapper.style.minHeight = `${this.wrapper.clientHeight}px`; } }; addResizeListener(wrapper, handleWrapperResize); // Resize container on animation end wrapper.addEventListener('animationend', (event) => { if (event.animationName === 'slide-in' && this.currentLibraryElement) { this.parent.trigger('resize'); window.setTimeout(() => { // Make the library resize then make the wrapper resize to the new library height addResizeListener(this.currentLibraryElement, () => { this.handleLibraryResize(); this.parent.bubblingUpwards = true; this.parent.trigger('resize'); this.parent.bubblingUpwards = false; }); }, 100); } }); return wrapper; } /** * Append back button. * @param {string} label Button label. * @return {HTMLElement} Back button. */ createBackButton(label) { const backButton = document.createElement('button'); backButton.classList.add('transition'); backButton.classList.add('h5p-back-button'); // Navigation backButton.addEventListener('click', (event) => { // Hide overlay popup when user is at Branching Question if (event.currentTarget.hasAttribute('isBQ')) { if (this.overlay) { if (this.overlay.parentNode !== null) { this.overlay.parentNode.removeChild(this.overlay); } this.overlay = undefined; this.branchingQuestions.forEach((bq) => { if (bq.parentNode !== null) { bq.parentNode.removeChild(bq); } }); this.showBackgroundToReadspeaker(); } // If the BQ is at first position, we need to restart the screen when user want to go back from the 2nd screen (next screen after BQ) if (this.parent.params.content[0].type.library.split(' ')[0] === 'H5P.BranchingQuestion' && this.parent.currentId === 0) { this.parent.trigger('restarted', { keepStates: true }); return backButton; } this.parent.trigger('navigated', { reverse: true }); return; } // Stop impatient users from breaking the view if (this.parent.navigating === true) { return; } if (this.currentLibraryId === 0 && this.parent.params.content[this.parent.currentId].type.library.split(' ')[0] !== 'H5P.BranchingQuestion') { this.parent.isReverseTransition = true; this.parent.trigger('restarted', { keepStates: true }); return backButton; } this.parent.trigger('navigated', { reverse: true }); this.parent.navigating = true; }); backButton.appendChild(document.createTextNode(label)); return backButton; } // Hande proceed to next slide. handleProceed() { let returnValue = true; // Stop impatient users from breaking the view if (this.parent.navigating === false) { const hasFeedbackTitle = this.libraryFeedback.title && this.libraryFeedback.title.trim(); const hasFeedbackSubtitle = this.libraryFeedback.subtitle && this.libraryFeedback.subtitle.trim(); const hasFeedback = !!(hasFeedbackTitle || hasFeedbackSubtitle || this.libraryFeedback.image ); if (hasFeedback && this.nextLibraryId !== -1) { // Add an overlay if it doesn't exist yet if (this.overlay === undefined) { this.overlay = document.createElement('div'); this.overlay.className = 'h5p-branching-scenario-overlay'; this.wrapper.appendChild(this.overlay); this.hideBackgroundFromReadspeaker(); } const branchingQuestion = document.createElement('div'); branchingQuestion.classList.add('h5p-branching-question-wrapper'); branchingQuestion.classList.add('h5p-branching-scenario-feedback-dialog'); const questionContainer = document.createElement('div'); questionContainer.classList.add('h5p-branching-question-container'); branchingQuestion.appendChild(questionContainer); const feedbackScreen = this.createFeedbackScreen(this.libraryFeedback, this.nextLibraryId); questionContainer.appendChild(feedbackScreen); questionContainer.classList.add('h5p-start-outside'); questionContainer.classList.add('h5p-fly-in'); this.currentLibraryWrapper.style.zIndex = 0; setTimeout(() => { // Small wait for safari browsers this.wrapper.appendChild(branchingQuestion); // After adding feedback, check whether the resize is needed or not if (parseInt(this.currentLibraryWrapper.style.height) < questionContainer.offsetHeight) { this.currentLibraryElement.style.height = questionContainer.offsetHeight + 'px'; this.wrapper.style.height = questionContainer.offsetHeight + 'px'; this.parent.trigger('resize'); } }, 100); feedbackScreen.focus(); this.parent.navigating = true; } else { const nextScreen = { nextContentId: this.nextLibraryId }; if (!!(hasFeedback || (this.libraryFeedback.endScreenScore !== undefined))) { nextScreen.feedback = this.libraryFeedback; } // Allow user to naviate to next slide/library if the execution completes returnValue = false; new Promise((resolve) => { resolve(this.parent.trigger('navigated', nextScreen)); }).then(() => { requestAnimationFrame(() => { this.parent.trigger('resize'); }); this.parent.proceedButtonInProgress = false; this.parent.navigating = true; }); } } // Return to Proceed button listener with response if (returnValue) { return returnValue; } } createFeedbackScreen(feedback, nextContentId) { const labelId = 'h5p-branching-feedback-title-' + LibraryScreen.idCounter++; const wrapper = document.createElement('div'); wrapper.classList.add('h5p-branching-question'); wrapper.classList.add(feedback.image !== undefined ? 'h5p-feedback-has-image' : 'h5p-feedback-default'); wrapper.setAttribute('role', 'dialog'); wrapper.setAttribute('tabindex', '-1'); wrapper.setAttribute('aria-labelledby', labelId); if (feedback.image !== undefined && feedback.image.path !== undefined) { const imageContainer = document.createElement('div'); imageContainer.classList.add('h5p-branching-question'); imageContainer.classList.add('h5p-feedback-image'); const image = document.createElement('img'); image.src = H5P.getPath(feedback.image.path, this.parent.contentId); imageContainer.appendChild(image); wrapper.appendChild(imageContainer); } const feedbackContent = document.createElement('div'); feedbackContent.classList.add('h5p-branching-question'); feedbackContent.classList.add('h5p-feedback-content'); const feedbackText = document.createElement('div'); feedbackText.classList.add('h5p-feedback-content-content'); feedbackContent.appendChild(feedbackText); const title = document.createElement('h1'); title.id = labelId; title.innerHTML = feedback.title || ''; feedbackText.appendChild(title); if (feedback.subtitle) { const subtitle = document.createElement('div'); subtitle.innerHTML = feedback.subtitle || ''; feedbackText.appendChild(subtitle); } const navButton = document.createElement('button'); navButton.addEventListener('click', () => { this.parent.trigger('navigated', { nextContentId }); requestAnimationFrame(() => { this.parent.trigger('resize'); }); }); const proceedButtonText = this.parent.getLibrary(this.currentLibraryId).proceedButtonText; const text = document.createTextNode(proceedButtonText); navButton.appendChild(text); feedbackContent.appendChild(navButton); wrapper.appendChild(feedbackContent); return wrapper; } /** * Create library element and hide it if necessary. * @param {object} library Library object. * @param {boolean} isNextLibrary Determines if the lirbary should be hidden for now. * @returns {HTMLElement} Wrapping div for the library element. */ createLibraryElement(library, isNextLibrary) { const wrapper = document.createElement('div'); wrapper.classList.add('h5p-library-wrapper'); const libraryElement = document.createElement('div'); libraryElement.classList.add('h5p-branching-scenario-content'); this.appendRunnable(libraryElement, library.type, library.contentId); const libraryMachineName = library.type?.library.split(' ')[0]; // Content overlay required for some instances this.contentOverlays[library.contentId] = new LibraryScreenOverlay(this); wrapper.appendChild(this.contentOverlays[library.contentId].getDOM()); if (libraryMachineName === 'H5P.InteractiveVideo' || libraryMachineName === 'H5P.Video') { this.contentOverlays[library.contentId].addButton( 'replay', this.parent.params.l10n.replayButtonText, () => { this.handleReplayVideo(libraryMachineName, library); } ); this.contentOverlays[library.contentId].addButton( 'proceed', library.proceedButtonText, () => { this.handleProceedAfterVideo(); } ); } wrapper.appendChild(libraryElement); if (isNextLibrary) { wrapper.classList.add('h5p-next'); libraryElement.classList.add('h5p-branching-hidden'); } // Special case when first node is BQ and library screen tries to display it if (libraryMachineName === 'H5P.BranchingQuestion') { libraryElement.classList.add('h5p-branching-hidden'); } return wrapper; } handleReplayVideo(libraryMachineName, library) { this.contentOverlays[this.currentLibraryId].hide(); // Hide procced button if ( this.libraryFinishingRequirements[library.contentId] === true && this.hasValidVideo(library) ) { this.parent.disableNavButton(); } // sets buffering state for video this.currentLibraryInstance.currentState = 3; this.currentLibraryInstance.seek(0); this.currentLibraryInstance.play(); if (libraryMachineName === 'H5P.InteractiveVideo') { this.resetIVProgress(); } } /** * Used to reset an IV after you replay it. */ resetIVProgress() { let interactions = this.currentLibraryInstance.interactions; interactions.forEach((interaction) => { interaction.resetTask(); }); let interactiveVideo = this.currentLibraryInstance; interactiveVideo.addSliderInteractions(); if (!interactiveVideo.endscreen) { return; } interactiveVideo.endscreen.update(); interactiveVideo.endscreen.$closeButton[0].click(); let ivSubmitScreenStar = this.wrapper.getElementsByClassName('h5p-star-foreground')[0]; ivSubmitScreenStar.classList.remove('h5p-star-active'); } /** * Handle proceed after video. */ handleProceedAfterVideo() { this.contentOverlays[this.currentLibraryId].hide(); this.handleProceed(); } /** * Create new content instance from given content parameters and * attach it to wrapper. Set up event listeners. * @param {object} container Container the library should be appended to * @param {object} content Data for the library * @param {number} id Id of the library */ appendRunnable(container, content, id) { const library = content.library.split(' ')[0]; if (library === 'H5P.Video') { // Prevent video from growing endlessly since height is unlimited. content.params.visuals.fit = false; } else if (library === 'H5P.BranchingQuestion') { const proceedButtonText = this.parent.getLibrary(id).proceedButtonText; content.params.proceedButtonText = proceedButtonText; } /* * Deep clone paramters to prevent modification (since they're reused each * time the course is reset). * `structuredClone(content)` can be used to replace the jQuery dependency, * when jQuery is removed from H5P core, but currently it's not supported by * Safari 15.4 which would mean to still violate the latest 3 browsers rule. */ const contentClone = window.structuredClone ? structuredClone(content) : H5P.jQuery.extend(true, {}, content); /* * The logic of how instances and their wrappers are handled seems to have * grown over time. It's not obvious why new instances get created over and * over again by different callers instead of instantiating them once by a * manager and let them be served as needed. This would probably mean major * refactoring (together with the intertwined class code). * As a workaround, the previous state of the last library instance that was * shown is passed back when instantiating the LibraryScreen in order to be * sure it's available no matter by what path newRunnable is called. */ const extras = { parent: this.parent }; const previousStateIsForCurrentId = typeof(this.lastNodeId) === 'number' && this.lastNodeId === id; if (this.previousState && previousStateIsForCurrentId) { extras.previousState = this.previousState; } // Create content instance const instance = H5P.newRunnable( contentClone, this.parent.contentId, H5P.jQuery(container), true, extras ); if ( this.parent.params.content[id].forceContentFinished === 'enabled' || this.parent.params.content[id].forceContentFinished === 'useBehavioural' && this.parent.params.behaviour.forceContentFinished === true ) { this.libraryFinishingRequirements[id] = this.forceContentFinished(instance, library); this.addFinishedListeners(instance, library); } instance.setActivityStarted(); // Proceed to Branching Question automatically after video has ended if (library === 'H5P.Video' && this.nextIsBranching(id)) { instance.on('stateChange', (event) => { if (event.data === H5P.Video.ENDED && this.navButton) { this.handleProceed(); } }); } else if (library === 'H5P.Image') { // Ensure that iframe is resized when image is loaded. instance.on('loaded', () => { this.handleLibraryResize(); this.parent.trigger('resize'); }); } if (library === 'H5P.Video' || library === 'H5P.InteractiveVideo') { const videoInstance = (library === 'H5P.Video') ? instance : instance.video; videoInstance.on('loaded', () => { this.handleLibraryResize(); }); videoInstance.on('error', () => { this.parent.enableNavButton(); }); } instance.on('navigated', (event) => { this.parent.trigger('navigated', event.data); }); this.libraryInstances[id] = instance; // Bubble resize events this.bubbleUp(instance, 'resize', this.parent); // Remove any fullscreen buttons this.disableFullscreen(instance); } /** * Get current instance state. * @returns {object} Current state. */ getCurrentState() { return this.currentLibraryInstance?.getCurrentState?.(); } /** * Try to stop any playback on instance. * @param {number} id Id of instance node. */ stopPlayback(id) { const instance = this.libraryInstances[id]; if (instance) { try { if (typeof instance.pause === 'function') { instance.pause(); } else if (typeof instance.video?.pause === 'function') { instance.video.pause(); } else if (typeof instance.stop === 'function') { instance.stop(); } else if ( typeof instance.pauseMedia === 'function' && instance.elementInstances[instance.currentSlideIndex] ) { instance.elementInstances[instance.currentSlideIndex] .forEach((element) => { instance.pauseMedia(element); }); } } catch (err) { // Prevent crashing, but tell developers there's something wrong. H5P.error(err); } } } /** * Check whether instance needs to be finished by user. * @param {object} instance Instance of the content type. * @param {string} library Library that's active on screen (H5P.Foo). */ forceContentFinished(instance, library) { let forceContentFinished = false; if (instance) { forceContentFinished = forceContentFinished || typeof instance?.getScore === 'function'; } /* * Some libraries need to tuned explicitly because there's no way to * detect whether they are a "finishable" content type */ if (library) { forceContentFinished = forceContentFinished || (library === 'H5P.Audio' || library === 'H5P.Video'); } // Exceptions if ( library === 'H5P.CoursePresentation' && (instance?.children.length + (instance.isTask ? 1 : 0) === 1) || instance?.activeSurface === true ) { forceContentFinished = false; } return forceContentFinished; } /** * Add listeners for screen finished. * Will require to handle some content types explicitly. * @param {object} instance Instance of the content type. * @param {string} library Library that's active on screen (H5P.Foo). */ addFinishedListeners(instance, library) { if (typeof library !== 'string' || !instance) { return; } switch (library) { case 'H5P.CoursePresentation': // Permit progression when final slide has been reached instance.on('xAPI', (event) => { if (event.data.statement.verb.display['en-US'] === 'progressed') { const slideProgressedTo = parseInt(event.data.statement.object.definition.extensions['http://id.tincanapi.com/extension/ending-point']); if (slideProgressedTo === instance.children.length + (instance.isTask ? 1 : 0)) { if (this.navButton.classList.contains('h5p-disabled')) { this.parent.enableNavButton(true); } } } }); break; case 'H5P.InteractiveVideo': // Permit progression when results have been submitted or video ended if no tasks instance.on('xAPI', (event) => { if (event.data.statement.verb.display['en-US'] === 'completed') { this.handleVideoOver(); } }); instance.video.on('stateChange', (event) => { if ( event.data === H5P.Video.ENDED || ( event.data === H5P.Video.PLAYING && this.contentOverlays[this.currentLibraryId].hidden === false ) ) { const answered = instance.interactions .filter((interaction) => interaction.getProgress() !== undefined); // Giving opportunity to submit the answers if (instance.hasStar && answered.length > 0) { this.parent.enableNavButton(); } else { this.handleVideoOver(); } this.pause(); } }); break; // Permit progression when video ended case 'H5P.Video': instance.on('stateChange', (event) => { if (event.data === H5P.Video.ENDED) { if (!this.nextIsBranching(this.currentLibraryId)) { this.handleVideoOver(); } // else already handled by general video listener } }); break; // Permit progression when audio ended case 'H5P.Audio': instance.audio.on('ended', () => { this.parent.enableNavButton(); }); break; // Permit progression when xAPI sends "answered" or "completed" default: if (typeof instance.getAnswerGiven === 'function') { instance.on('xAPI', (event) => { if ( event.data.statement.verb.display['en-US'] === 'answered' || event.data.statement.verb.display['en-US'] === 'completed' ) { this.parent.enableNavButton(); } }); } } } /** * Handle video completed. * Will proceed right away if next node is BQ, otherwise show intermediary overlay. */ handleVideoOver() { if (this.nextIsBranching(this.currentLibraryId)) { this.handleProceed(); } else { this.showContentOverlay(); } this.parent.enableNavButton(); } /** * Show content overlay. */ showContentOverlay() { this.contentOverlays[this.currentLibraryId].show(); } /** * Hide content overlay. */ hideContentOverlay() { this.contentOverlays[this.currentLibraryId].hide(); } /** * Get XAPI data for "previous" library. * @param {number} id Id of the instance node. * @return {object} XAPI Data. */ getXAPIData(id) { if (this.libraryInstances[id] && this.libraryInstances[id].getXAPIData) { return this.libraryInstances[id].getXAPIData(); } } /** * Check if next node is a Branching Question. * @param {number} id Id of node to check for. * @return {boolean} True, if next node is BQ, else false. */ nextIsBranching(id) { const nextContentId = (id !== undefined) ? this.parent.params.content[id].nextContentId : undefined; return (nextContentId !== undefined && nextContentId > 0) ? LibraryScreen.isBranching(this.parent.params.content[nextContentId]) : false; } /** * Pre-render next libraries for smooth transitions for specific library. * @param {object} library Library Data. */ createNextLibraries(library) { this.removeNextLibraries(); this.nextLibraries = {}; this.loadLibrary(library); } /** * Create next library. * @param {object} library. */ createNextLibrary(library) { this.removeNextLibraries(); this.nextLibraries = {}; this.loadLibrary(library, library.contentId); } /** * Load library. * @param {object} library Library. * @param {number} [contentId] Id of loaded library. */ loadLibrary(library, contentId = null) { const loadedContentId = contentId !== null ? contentId : library.nextContentId; // If not a branching question, just load the next library if (library.type.library.split(' ')[0] !== 'H5P.BranchingQuestion') { const nextLibrary = this.parent.getLibrary(loadedContentId); // Do nothing if the next screen is an end screen if (nextLibrary === false) { return; } // Pre-render the next library if it is not a branching question if ( nextLibrary?.type.library.split(' ')[0] !== 'H5P.BranchingQuestion' ) { this.nextLibraries[loadedContentId] = this.createLibraryElement(nextLibrary, true); this.wrapper.appendChild(this.nextLibraries[loadedContentId]); } } // If it is a branching question, load all the possible libraries else { const alternatives = library.type.params.branchingQuestion.alternatives || []; alternatives .map((alternative) => alternative.nextContentId) .forEach((nextContentId) => { const nextLibrary = this.parent.getLibrary(nextContentId); // Do nothing if the next screen is an end screen if (nextLibrary === false) { return; } // Pre-render all the next libraries as long as they are not branching questions if ( nextLibrary?.type.library.split(' ')[0] !== 'H5P.BranchingQuestion' ) { this.nextLibraries[nextContentId] = this.createLibraryElement(nextLibrary, true); this.wrapper.appendChild(this.nextLibraries[nextContentId]); } }); } } /** * Remove next libraries */ removeNextLibraries() { // Remove outdated 'next' libraries const nextLibraryElements = [...this.wrapper.getElementsByClassName('h5p-next')]; nextLibraryElements.forEach((nextLibraryElement) => { nextLibraryElement.parentNode.removeChild(nextLibraryElement); }); } /** * Remove custom fullscreen buttons from sub content. * (A bit of a hack, there should have been some sort of override…) * @param {object} instance Library instance */ disableFullscreen(instance) { switch (instance.libraryInfo.machineName) { case 'H5P.CoursePresentation': if (instance.$fullScreenButton) { instance.$fullScreenButton.remove(); } break; case 'H5P.InteractiveVideo': instance.on('controls', () => { if (instance.controls.$fullscreen) { instance.controls.$fullscreen.remove(); } }); break; } } /** * Make it easy to bubble events from child to parent * @param {object} origin Origin of the Event * @param {string} eventName Name of the Event * @param {object} target Target to trigger event on */ bubbleUp(origin, eventName, target) { origin.on(eventName, (event) => { // Prevent target from sending event back down target.bubblingUpwards = true; target.trigger(eventName, event); // Reset target.bubblingUpwards = false; }); } /** * Checks to see if the library has a valid video (source file or external link). * video/unknown check is to verify that external Youtube links work correctly. */ hasValidVideo(currentLibraryParams) { const type = currentLibraryParams.type; const videoLibrary = type.metadata.contentType; let videoSource = videoLibrary === 'Interactive Video' ? type.params.interactiveVideo.video.files : type.params.sources; if (type && (videoLibrary === 'Interactive Video' || videoLibrary === 'Video') && videoSource && videoSource[0].mime && videoSource[0].mime !== 'video/unknown' && ( ( videoSource[0].mime !== 'video/webm' && videoSource[0].mime !== 'video/mp4' ) || H5P.VideoHtml5.canPlay(videoSource) ) ) { return true; } return false; } /** * Slide screen in and style it as the current screen. * @param {object} [options] Options. * @param {boolean} [options.skipAnimation] True to skip animation. * @param {boolean} [options.skipFocus] True to skip focus. */ show(options = {}) { const library = this.parent.params.content[this.currentLibraryId]; if ( this.libraryFinishingRequirements[this.currentLibraryId] === true && ( this.hasValidVideo(library) || library.type.library.split(' ')[0] === 'H5P.CoursePresentation' ) ) { this.contentOverlays[this.currentLibraryId].hide(); this.parent.disableNavButton(); } this.isShowing = true; this.wrapper.classList.remove('h5p-branching-hidden'); const done = () => { this.wrapper.classList.remove('h5p-next-screen'); this.wrapper.classList.remove('h5p-slide-in'); this.wrapper.classList.add('h5p-current-screen'); this.parent.navigating = false; this.wrapper.style.minHeight = this.parent.currentHeight; if (!options.skipFocus) { this.libraryTitle.focus(); } }; if (options.skipAnimation) { done(); } else { this.wrapper.classList.add('h5p-slide-in'); // Style as the current screen this.wrapper.addEventListener('animationend', (event) => { if (event.target.className === 'h5p-next-screen h5p-slide-in') { done(); } }); } } /** * Slide screen out and style it to be hidden. * @param {boolean} skipAnimationListener Skips waiting for animation before removing elements. Useful when animation would not have time to run anyway. */ hide(skipAnimationListener) { this.isShowing = false; // Remove possible alternative libaries for (let i = 0; i < this.nextLibraries.length; i++) { // Ensures it is hidden if remove() doesn't execute quickly enough this.nextLibraries[i].style.display = 'none'; if (this.nextLibraries[i].parentNode !== null) { this.nextLibraries[i].parentNode.removeChild(this.nextLibraries[i]); } } // Hide overlay and branching questions if (this.overlay) { if (this.overlay.parentNode !== null) { this.overlay.parentNode.removeChild(this.overlay); } // TODO: Does not appear to ever run... this.overlay = undefined; this.branchingQuestions.forEach((bq) => { if (bq.parentNode !== null) { bq.parentNode.removeChild(bq); } }); } this.wrapper.classList.add('h5p-slide-out'); const removeElements = () => { this.wrapper.classList.remove('h5p-current-screen'); this.wrapper.classList.add('h5p-next-screen'); this.wrapper.classList.remove('h5p-slide-out'); this.wrapper.classList.remove('h5p-slide-out-reverse'); this.wrapper.classList.remove('h5p-slide-pseudo'); setTimeout(() => { if (this.wrapper.parentNode !== null) { this.wrapper.parentNode.removeChild(this.wrapper); this.remove(); this.parent.trigger('resize'); } }, 100); }; if (skipAnimationListener) { window.setTimeout(() => { removeElements(); }, 800); } else { this.wrapper.addEventListener('animationend', () => { removeElements(); }); } } /** * Hide feedback dialogs. */ hideFeedbackDialogs() { if (this.overlay) { if (this.overlay.parentNode !== null) { this.overlay.parentNode.removeChild(this.overlay); } this.overlay = undefined; this.showBackgroundToReadspeaker(); } const wrapper = document.querySelector('.h5p-current-screen'); if (!wrapper) { return; } const questionWrapper = wrapper.querySelector('.h5p-branching-question-wrapper'); if (questionWrapper) { questionWrapper.parentNode.removeChild(questionWrapper); } } /** * Ensure that start screen can contain branching questions. * @param {boolean} isStartScreen True if resizing start screen. */ resizeScreen(isStartScreen = false) { // Ensure start screen expands to encompass large branching questions if (!this.questionContainer) { return; } let screenWrapper = isStartScreen ? this.parent.startScreen.screenWrapper : this.wrapper; const style = window.getComputedStyle(this.questionContainer, null); const paddingTop = parseInt(style.getPropertyValue('padding-top')); screenWrapper.style.height = `${this.questionContainer.offsetHeight + paddingTop}px`; } /** * Slide in next library which may be either a 'normal content type' or a * branching question * @param {object} library Library data. * @param {boolean} [reverse] True to slide in from the left. * @param {boolean} [skipAnimation] True to skip animation. * @param {boolean} [skipFocus] True to skip focus. */ showNextLibrary( library, reverse = false, skipAnimation = false, skipFocus = false ) { this.nextLibraryId = library.nextContentId; this.libraryFeedback = library.feedback; // Show normal h5p library if (!LibraryScreen.isBranching(library)) { let showProceedButtonflag = true; // First priority - Hide navigation button first to prevent user to make unecessary clicks if (this.libraryFinishingRequirements[library.contentId] === true && (this.hasValidVideo(library) || library.type.library.split(' ')[0] === 'H5P.CoursePresentation')) { this.contentOverlays[this.currentLibraryId]?.hide(); this.parent.disableNavButton(); showProceedButtonflag = false; } // Update proceed button text const proceedButton = document.querySelector('.h5p-proceed-button'); proceedButton.innerHTML = library.proceedButtonText; // Update the title const contentTitle = (library.type && library.type.metadata && library.type.metadata.title ? library.type.metadata.title : ''); this.libraryTitle.setAttribute('aria-label', contentTitle ? contentTitle : 'Untitled Content'); this.libraryTitle.innerHTML = (library.showContentTitle && contentTitle ? contentTitle : ' '); if (!skipAnimation) { if (this.currentLibraryId === library.contentId) { // Target slide is already being displayed this.currentLibraryWrapper.classList.add('h5p-slide-pseudo'); } else if (reverse) { // Slide out the current library in reverse direction this.currentLibraryWrapper.classList.add('h5p-slide-out-reverse'); } else { // Slide out the current library this.currentLibraryWrapper.classList.add('h5p-slide-out'); } } // Remove the branching questions if they exist if (this.overlay) { if (this.overlay.parentNode !== null) { this.overlay.parentNode.removeChild(this.overlay); } this.overlay = undefined; this.branchingQuestions.forEach((bq) => { if (bq.parentNode !== null) { bq.parentNode.removeChild(bq); } }); this.showBackgroundToReadspeaker(); } // Initialize library if necessary if (!this.nextLibraries[library.contentId]) { this.createNextLibrary(library); } // Slide in selected library const libraryWrapper = this.nextLibraries[library.contentId]; if (!libraryWrapper.offsetParent) { const footerWrapper = document.querySelector('.h5p-screen-footer'); this.wrapper.insertBefore(libraryWrapper, footerWrapper); } // Move next library left of current library if sliding backwards if (reverse) { libraryWrapper.classList.remove('h5p-next'); libraryWrapper.classList.add('h5p-previous'); } const done = () => { if (this.currentLibraryWrapper.parentNode !== null) { this.currentLibraryWrapper.parentNode.removeChild(this.currentLibraryWrapper); } this.currentLibraryWrapper = libraryWrapper; this.wrapper.setAttribute('aria-hidden', false); this.currentLibraryWrapper.classList.remove('h5p-previous'); this.currentLibraryWrapper.classList.remove('h5p-next'); this.currentLibraryWrapper.classList.remove('h5p-slide-in'); this.currentLibraryElement = libraryWrapper.getElementsByClassName('h5p-branching-scenario-content')[0]; // TODO: Why no use 'libraryElement' ? this.createNextLibraries(library); this.parent.navigating = false; if (!skipFocus) { this.libraryTitle.focus(); } // New position to show Proceed button because sometimes user can play with the button while animation is in progress if (showProceedButtonflag) { this.parent.enableNavButton(); } // Require to call resize the frame after animation completes this.resize(new H5P.Event('resize', { element: libraryElement })); }; if (!skipAnimation) { libraryWrapper.classList.add('h5p-slide-in'); } const libraryElement = libraryWrapper .getElementsByClassName('h5p-branching-scenario-content')[0]; libraryElement.classList.remove('h5p-branching-hidden'); this.currentLibraryId = library.contentId; this.currentLibraryInstance = this.libraryInstances[library.contentId]; if (this.currentLibraryInstance.resize) { this.currentLibraryInstance.resize(); } if (skipAnimation) { done(); } else { this.currentLibraryWrapper.addEventListener('animationend', () => { done(); }); } } else { // Show a branching question if (this.parent.params.behaviour === true) { this.parent.disableNavButton(); } // Remove existing branching questions this.branchingQuestions.forEach((bq) => { if (bq.parentNode !== null) { bq.parentNode.removeChild(bq); } }); // BS could be showing start screen or library screen const wrapper = document.querySelector('.h5p-current-screen'); // Add an overlay if it doesn't exist yet if (this.overlay === undefined) { this.overlay = document.createElement('div'); this.overlay.className = 'h5p-branching-scenario-overlay'; wrapper.appendChild(this.overlay); this.hideBackgroundFromReadspeaker(); } const buttonWrapper = document.createElement('div'); buttonWrapper.classList.add('h5p-nav-button-wrapper'); // Append back button if at least one node has it enabled if (this.parent.backwardsAllowedFlags.indexOf(true) !== -1) { this.bqBackButton = this.createBackButton(this.parent.params.l10n.backButtonText); this.bqBackButton.setAttribute('isBQ', true); // Check the back button is enable or not if (this.parent.canEnableBackButton(library.contentId) === false) { this.bqBackButton.classList.add('h5p-disabled'); this.bqBackButton.setAttribute('disabled', true); } buttonWrapper.appendChild(this.bqBackButton); } const branchingQuestion = document.createElement('div'); branchingQuestion.className = 'h5p-branching-question-wrapper'; this.appendRunnable(branchingQuestion, library.type, library.contentId); wrapper.appendChild(branchingQuestion); this.branchingQuestions.push(branchingQuestion); this.currentLibraryId = library.contentId; this.currentLibraryInstance = this.libraryInstances[library.contentId]; const labelId = 'h5p-branching-question-title-' + LibraryScreen.idCounter++; const questionContainer = branchingQuestion.querySelector('.h5p-branching-question-container'); this.questionContainer = questionContainer; questionContainer.setAttribute('role', 'dialog'); questionContainer.setAttribute('tabindex', '-1'); questionContainer.setAttribute('aria-labelledby', labelId); if (!skipAnimation) { questionContainer.classList.add('h5p-start-outside'); } questionContainer.classList.add('h5p-fly-in'); branchingQuestion.querySelector('.h5p-branching-question-title').id = labelId; const multiChoiceWrapper = branchingQuestion.querySelector('.h5p-multichoice-wrapper'); multiChoiceWrapper.setAttribute('role', 'group'); multiChoiceWrapper.setAttribute('aria-labelledby', labelId); document.querySelector('.h5p-branching-question').appendChild(buttonWrapper); this.currentLibraryWrapper.style.zIndex = 0; /** * Resizes the wrapper to the height of the container. If the current BQ is at the very start of the content type then resize parent wrapper * Make exception for starting screen, so it does not cut from the top, as well as fullscreen. */ const isFullscreen = this.parent.isFullScreen(); const isSmallerDevice = this.parent.container.classList.contains('h5p-mobile-screen'); if (this.currentLibraryWrapper.style.height === '' && !this.parent.startScreen.isShowing && !isFullscreen && !isSmallerDevice) { this.resizeScreen(); } else if (this.parent.startScreen.isShowing && !isFullscreen) { // Ensure start screen expands to encompass large branching questions this.resizeScreen(true); } else if (parseInt(this.currentLibraryWrapper.style.height) < questionContainer.offsetHeight) { this.currentLibraryWrapper.style.height = questionContainer.offsetHeight + 'px'; } this.createNextLibraries(library); this.parent.navigating = false; branchingQuestion.addEventListener('animationend', () => { const firstAlternative = branchingQuestion .querySelectorAll('.h5p-branching-question-alternative')[0]; if (!skipFocus && typeof firstAlternative !== 'undefined') { questionContainer.focus(); } }); } } /** * Hide background from Readspeaker. */ hideBackgroundFromReadspeaker() { this.header.setAttribute('aria-hidden', 'true'); this.currentLibraryWrapper.setAttribute('aria-hidden', 'true'); // The section below tries to make all elements untabbable // (to avoid tabbing to background elements) let h5pContainer = document.querySelector('.h5p-container'); if (!h5pContainer) { // Probably in preview (where .h5p-container does not exist) h5pContainer = document.querySelector('.preview-wrapper'); if (!h5pContainer) { return; } } const selector = 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button, iframe, object, embed, *[tabindex], *[contenteditable], video, audio'; this.tabbables = []; h5pContainer.querySelectorAll(selector).forEach((element) => { if (element instanceof HTMLMediaElement) { // For native Media elements (audio & video) we can't set the tab index. // Instead, we'll just remove the controls, which makes it non-tabbable if (element.controls) { element.controls = false; this.tabbables.push({ element: element }); } // If no controls - no need to do anything when showBackgroundToReadspeaker is invoked } else { // Store current tabindex, so we can set it back when dialog closes // Specifically handle jquery ui slider, since it overwrites data in an inconsistent way const currentTabindex = element.classList.contains('ui-slider-handle') ? 0 : element.getAttribute('tabindex'); // Make it untabbable element.setAttribute('tabindex', '-1'); this.tabbables.push({ element: element, tabindex: currentTabindex }); } }); } /** * Show background to Readspeaker. */ showBackgroundToReadspeaker() { this.header.setAttribute('aria-hidden', 'false'); this.currentLibraryWrapper.setAttribute('aria-hidden', 'false'); // Resets tabindex to the original state if (this.tabbables) { this.tabbables.forEach((tabbable) => { const element = tabbable.element; if (element instanceof HTMLMediaElement) { element.controls = true; } else if (tabbable.tabindex !== undefined && tabbable.tabindex !== null) { element.setAttribute('tabindex', tabbable.tabindex); } else { element.removeAttribute('tabindex'); } }); this.tabbables = null; } } getElement() { return this.wrapper; } remove() { if (this.wrapper.parentNode !== null) { this.wrapper.parentNode.removeChild(this.wrapper); } } resize(event) { const instance = this.currentLibraryInstance; const element = event?.data?.element ?? this.currentLibraryElement; const isImage = (instance?.libraryInfo.machineName === 'H5P.Image'); const isCP = (instance?.libraryInfo.machineName === 'H5P.CoursePresentation'); const isHotspots = (instance?.libraryInfo.machineName === 'H5P.ImageHotspots'); const isVideo = (instance?.libraryInfo.machineName === 'H5P.Video'); const isIV = (instance?.libraryInfo.machineName === 'H5P.InteractiveVideo'); const hasSize = (instance?.width && instance.height); const isYoutube = element.classList.contains('h5p-youtube'); const canScaleImage = ( (hasSize && (isImage || isCP)) || isHotspots || isVideo ); if (canScaleImage) { // Always reset scaling element.style.width = ''; element.style.height = ''; if (isHotspots) { element.style.maxWidth = ''; } } // Toggle full screen class for content (required for IV to resize properly) if (this.parent.isFullScreen()) { element.classList.add('h5p-fullscreen'); if (isIV && instance.$videoWrapper[0].firstChild.style) { instance.videoHeight = instance.$videoWrapper[0].firstChild.style.height; } // Preserve aspect ratio for Image in fullscreen (since height is limited) instead of scrolling or streching if (canScaleImage) { const videoRect = isVideo && this.parent.params.content[this.currentLibraryId].type.params.sources !== undefined ? element.getBoundingClientRect() : null; // Video with no source should appear on top if ( isVideo && this.parent.params.content[this.currentLibraryId].type.params.sources === undefined ) { element.classList.add('h5p-video-no-source'); } else { element.classList.remove('h5p-video-no-source'); } if (videoRect || isHotspots || isCP || isImage) { const height = isHotspots ? instance.options.image.height : (isVideo ? videoRect.height : instance.height); const width = isHotspots ? instance.options.image.width : (isCP ? instance.ratio * height : (isVideo ? videoRect.width : instance.width)); const aspectRatio = (height / width); const targetElement = isIV ? element.lastChild : element; const availableSpace = targetElement.getBoundingClientRect(); const availableAspectRatio = (availableSpace.height / availableSpace.width); if (aspectRatio > availableAspectRatio) { const size = `${availableSpace.height * (width / height)}px`; if (isHotspots) { targetElement.style.maxWidth = size; } else { targetElement.style.width = size; } } else { const size = `${availableSpace.width * aspectRatio}px`; targetElement.style.height = size; if (isYoutube && element.querySelector('iframe') !== null) { element.querySelector('iframe').style.height = size; } } } } } else { // Fullscreen with branching question must set wrapper size if (this.parent.startScreen.isShowing) { this.resizeScreen(true); } else if (this.overlay) { this.resizeScreen(); } else { // reset wrapper height this.wrapper.style.height = ''; } const videoWrapperInstance = element.getElementsByClassName('h5p-video-wrapper'); if (isIV && videoWrapperInstance.length > 0) { let videoWrapper = videoWrapperInstance[0].firstChild; if (videoWrapper.style) { videoWrapper.style.height = instance.videoHeight; } } else if (isYoutube && element.querySelector('iframe') !== null) { element.querySelector('iframe').style.height = ''; } element.classList.remove('h5p-fullscreen'); } if (instance) { instance.trigger('resize', event); // Must resize library screen after resizing content this.handleLibraryResize(); } } /** * Check if library is a Branching Question. * @param {object} library * @returns {boolean} True if library is a Branching Question. */ static isBranching(library) { if (!library?.type?.library) { return false; } return library.type.library.indexOf('H5P.BranchingQuestion ') === 0; } /** * Create navigation buttons */ createNavButtons() { const buttonWrapper = document.createElement('div'); buttonWrapper.classList.add('h5p-nav-button-wrapper'); // Append back button if at least one node has it enabled if (this.parent.backwardsAllowedFlags.indexOf(true) !== -1) { this.backButton = this.createBackButton(this.parent.params.l10n.backButtonText); buttonWrapper.appendChild(this.backButton); } // Proceed button this.navButton = document.createElement('button'); this.navButton.classList.add('transition'); this.navButton.addEventListener('animationend', () => { this.parent.unanimateNavButton(); }); this.navButton.addEventListener('click', () => { if (this.parent.proceedButtonInProgress) { return; } this.parent.proceedButtonInProgress = true; new Promise((resolve) => { const response = this.handleProceed(); // Wait until receive positive response if (response) { resolve(true); } }).then(() => { this.parent.proceedButtonInProgress = false; }); }); this.navButton.classList.add('h5p-nav-button', 'h5p-proceed-button'); const proceedButtonText = this.parent.getLibrary(this.currentLibraryId).proceedButtonText; this.navButton.appendChild(document.createTextNode(proceedButtonText)); buttonWrapper.appendChild(this.navButton); const footer = document.createElement('div'); footer.classList.add('h5p-screen-footer'); footer.appendChild(buttonWrapper); this.wrapper.appendChild(footer); } /** * Reset instance. * @param {number} id Id of instance to reset. */ resetInstance(id) { const instance = id ? this.libraryInstances[id] : this.currentLibraryInstance; if (instance) { instance.resetTask?.(); } delete this.previousState; delete this.lastNodeId; } } LibraryScreen.idCounter = 0;
CANCEL
Name
Type
Size
Modified
Actions
↩ ..
DIR
—
—
📄 6943ef68f947b345cd45dd03084d8bdd7e8fc716
?
53.5 KB
2026-04-01 13:37
EDIT