1 // This file is part of Moodle - http://moodle.org/
3 // Moodle is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
8 // Moodle is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 // GNU General Public License for more details.
13 // You should have received a copy of the GNU General Public License
14 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16 /* global H5PEmbedCommunicator:true */
18 * When embedded the communicator helps talk to the parent page.
19 * This is a copy of the H5P.communicator, which we need to communicate in this context
21 * @type {H5PEmbedCommunicator}
23 * @copyright 2019 Joubel AS <contact@joubel.com>
24 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26 H5PEmbedCommunicator = (function() {
31 function Communicator() {
34 // Maps actions to functions.
35 var actionHandlers = {};
37 // Register message listener.
38 window.addEventListener('message', function receiveMessage(event) {
39 if (window.parent !== event.source || event.data.context !== 'h5p') {
40 return; // Only handle messages from parent and in the correct context.
43 if (actionHandlers[event.data.action] !== undefined) {
44 actionHandlers[event.data.action](event.data);
49 * Register action listener.
51 * @param {string} action What you are waiting for
52 * @param {function} handler What you want done
54 self.on = function(action, handler) {
55 actionHandlers[action] = handler;
59 * Send a message to the all mighty father.
61 * @param {string} action
62 * @param {Object} [data] payload
64 self.send = function(action, data) {
65 if (data === undefined) {
71 // Parent origin can be anything.
72 window.parent.postMessage(data, '*');
75 /* eslint-disable promise/avoid-new */
76 const repositoryPromise = new Promise((resolve) => {
77 require(['core_h5p/repository'], (Repository) => {
79 // Replace the default versions.
80 self.post = Repository.postStatement;
81 self.postState = Repository.postState;
82 self.deleteState = Repository.deleteState;
84 // Resolve the Promise with Repository to allow any queued calls to be executed.
90 * Send a xAPI statement to LMS.
92 * @param {string} component
93 * @param {Object} statements
96 self.post = (component, statements) => repositoryPromise.then((Repository) => Repository.postStatement(
102 * Send a xAPI state to LMS.
104 * @param {string} component
105 * @param {string} activityId
106 * @param {Object} agent
107 * @param {string} stateId
108 * @param {string} stateData
117 ) => repositoryPromise.then((Repository) => Repository.postState(
126 * Delete a xAPI state from LMS.
128 * @param {string} component
129 * @param {string} activityId
130 * @param {Object} agent
131 * @param {string} stateId
134 self.deleteState = (component, activityId, agent, stateId) => repositoryPromise.then((Repository) => Repository.deleteState(
142 return (window.postMessage && window.addEventListener ? new Communicator() : undefined);
145 var getH5PObject = async (iFrame) => {
146 var H5P = iFrame.contentWindow.H5P;
147 if (H5P?.instances?.[0]) {
151 // In some cases, the H5P takes a while to be initialized (which causes some random behat failures).
152 const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay));
153 let remainingAttemps = 10;
154 while (!H5P?.instances?.[0] && remainingAttemps > 0) {
156 H5P = iFrame.contentWindow.H5P;
162 document.onreadystatechange = async() => {
163 // Wait for instances to be initialize.
164 if (document.readyState !== 'complete') {
168 /** @var {boolean} statementPosted Whether the statement has been sent or not, to avoid sending xAPI State after it. */
169 var statementPosted = false;
171 // Check for H5P iFrame.
172 var iFrame = document.querySelector('.h5p-iframe');
173 if (!iFrame || !iFrame.contentWindow) {
176 var H5P = await getH5PObject(iFrame);
177 if (!H5P?.instances?.[0]) {
182 var instance = H5P.instances[0];
183 var parentIsFriendly = false;
185 // Handle that the resizer is loaded after the iframe.
186 H5PEmbedCommunicator.on('ready', function() {
187 H5PEmbedCommunicator.send('hello');
190 // Handle hello message from our parent window.
191 H5PEmbedCommunicator.on('hello', function() {
192 // Initial setup/handshake is done.
193 parentIsFriendly = true;
195 // Hide scrollbars for correct size.
196 iFrame.contentDocument.body.style.overflow = 'hidden';
198 document.body.classList.add('h5p-resizing');
200 // Content need to be resized to fit the new iframe size.
201 H5P.trigger(instance, 'resize');
204 // When resize has been prepared tell parent window to resize.
205 H5PEmbedCommunicator.on('resizePrepared', function() {
206 H5PEmbedCommunicator.send('resize', {
207 scrollHeight: iFrame.contentDocument.body.scrollHeight
211 H5PEmbedCommunicator.on('resize', function() {
212 H5P.trigger(instance, 'resize');
215 H5P.on(instance, 'resize', function() {
216 if (H5P.isFullscreen) {
217 return; // Skip iframe resize.
220 // Use a delay to make sure iframe is resized to the correct size.
221 clearTimeout(resizeDelay);
222 resizeDelay = setTimeout(function() {
223 // Only resize if the iframe can be resized.
224 if (parentIsFriendly) {
225 H5PEmbedCommunicator.send('prepareResize',
227 scrollHeight: iFrame.contentDocument.body.scrollHeight,
228 clientHeight: iFrame.contentDocument.body.clientHeight
232 H5PEmbedCommunicator.send('hello');
237 // Get emitted xAPI data.
238 H5P.externalDispatcher.on('xAPI', function(event) {
239 statementPosted = false;
240 var moodlecomponent = H5P.getMoodleComponent();
241 if (moodlecomponent == undefined) {
244 // Skip malformed events.
245 var hasStatement = event && event.data && event.data.statement;
250 var statement = event.data.statement;
251 var validVerb = statement.verb && statement.verb.id;
256 var isCompleted = statement.verb.id === 'http://adlnet.gov/expapi/verbs/answered'
257 || statement.verb.id === 'http://adlnet.gov/expapi/verbs/completed';
259 var isChild = statement.context && statement.context.contextActivities &&
260 statement.context.contextActivities.parent &&
261 statement.context.contextActivities.parent[0] &&
262 statement.context.contextActivities.parent[0].id;
264 if (isCompleted && !isChild) {
265 var statements = H5P.getXAPIStatements(this.contentId, statement);
266 H5PEmbedCommunicator.post(moodlecomponent, statements);
267 // Mark the statement has been sent, to avoid sending xAPI State after it.
268 statementPosted = true;
272 H5P.externalDispatcher.on('xAPIState', function(event) {
273 var moodlecomponent = H5P.getMoodleComponent();
274 var contentId = event.data.activityId;
275 var stateId = event.data.stateId;
276 var state = event.data.state;
277 if (state === undefined) {
278 // When state is undefined, a call to the WS for getting the state could be done. However, for now, this is not
279 // required because the content state is initialised with PHP.
283 if (state === null) {
284 // When this method is called from the H5P API with null state, the state must be deleted using the rest of attributes.
285 H5PEmbedCommunicator.deleteState(moodlecomponent, contentId, H5P.getxAPIActor(), stateId);
286 } else if (!statementPosted) {
287 // Only update the state if a statement hasn't been posted recently.
288 // When state is defined, it needs to be updated. As not all the H5P content types are returning a JSON, we need
289 // to simulate it because xAPI State defines statedata as a JSON.
293 H5PEmbedCommunicator.postState(moodlecomponent, contentId, H5P.getxAPIActor(), stateId, JSON.stringify(statedata));
297 // Trigger initial resize for instance.
298 H5P.trigger(instance, 'resize');