quizzer.js (15973B)
1 const quizzer = Vue.component("quizzer", { 2 props: { 3 startingPrompts: { 4 type: Array, 5 default: function() { 6 return []; 7 }, 8 }, 9 startingIndex: { 10 type: Number, 11 default: 0, 12 }, 13 settings: { 14 type: Object, 15 default: getSettings, 16 }, 17 }, 18 19 data: function() { 20 return { 21 prompts: this.startingPrompts, 22 index: this.startingIndex, 23 responce: "", 24 responceActive: true, 25 shortcuts: { 26 "á": /a`/g, 27 "é": /e`/g, 28 "í": /i`/g, 29 "ñ": /n`/g, 30 "ñ": /n~/g, 31 "ó": /o`/g, 32 "ú": /u`/g, 33 "ü": /u~/g, 34 }, 35 }; 36 }, 37 38 computed: { 39 /** 40 * Get The current prompt 41 * @returns {Array} - The current prompt 42 */ 43 prompt: function() { 44 if (this.index < this.prompts.length) { 45 return this.prompts[this.index]; 46 } 47 else { 48 return ["", "", "", ""]; 49 } 50 }, 51 52 /** 53 * Get the diff between the use responce and correct answer 54 * @returns {Array} - The diff object 55 */ 56 diff: function() { 57 if (this.settings.onMissedPrompt === "Correct me" && (this.settings.showDiff === "Always" || (this.settings.showDiff === "For single answers" && this.prompt[3].split(",").length === 1))) { 58 // Initialize result 59 let result = { 60 input: [], 61 answer: [], 62 }; 63 64 // Generate diff 65 // Go backwards (from answer to input) so that output case matches user input 66 let diff = Diff.diffChars(this.prompt[3], this.responce, {ignoreCase:true}); 67 68 // Populate result object 69 for (let i=0; i < diff.length; i++) { 70 if (diff[i].added) { 71 result.input.push({changed:true, value:diff[i].value}); 72 } 73 else if (diff[i].removed) { 74 result.answer.push({changed:true, value:diff[i].value.toLowerCase()}); 75 } 76 else { 77 result.input.push({changed:false, value:diff[i].value}); 78 result.answer.push({changed:false, value:diff[i].value.toLowerCase()}); 79 } 80 } 81 82 // Return diffs 83 return result; 84 } 85 else { 86 // Diff is disabled, return user responce and correct answer 87 return { 88 input: [{changed:false, value:this.responce}], 89 answer: [{changed:false, value:this.prompt[3].toLowerCase()}], 90 }; 91 } 92 }, 93 }, 94 95 methods: { 96 /** 97 * Handles keyup events and implements quizzer keyboard shortcuts 98 */ 99 keyup: function(e) { 100 if (this._inactive || e.altKey || e.shiftKey || e.metaKey) return; 101 if (e.keyCode === 13 && e.ctrlKey && document.activeElement.tagName !== "BUTTON") { 102 this.Reset(); 103 } 104 else if (e.keyCode === 13 && !e.ctrlKey && document.activeElement.tagName !== "BUTTON") { 105 this.Enter(); 106 } 107 }, 108 109 /** 110 * Give the user the next prompt and reset the quizzer 111 */ 112 Reset: function() { 113 // Get new prompt 114 this.index++; 115 if (this.index >= this.prompts.length) { 116 return; 117 } 118 119 // Show and hide elements 120 this.responceActive = true; 121 try { 122 // Will fail if not mounted 123 // If not mounted, input will be focused by v-focus directive once it is mounted 124 this.$refs.input.focus(); 125 } 126 catch { } 127 128 // Emit new-prompt event 129 this.$emit("new-prompt", this.prompts, this.index); 130 131 // Reset responce 132 this.responce = ""; 133 134 // Read prompt 135 if (this.settings.promptType !== "Text") { 136 this.Read(this.prompt[1], this.prompt[0]); 137 } 138 139 // Get voice input 140 if (this.settings.inputType !== "Text") { 141 // Create recognition object 142 var recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition || window.mozSpeechRecognition || window.msSpeechRecognition)(); 143 144 // Set language 145 if (this.prompt[2].toLowerCase().includes("english")) { 146 recognition.lang = 'en-US'; 147 } 148 else if (this.prompt[2].toLowerCase().includes("spanish")) { 149 recognition.lang = 'es-mx'; 150 } 151 152 // Set options 153 recognition.continuous = true; 154 recognition.interimResults = false; 155 recognition.maxAlternatives = 16; 156 157 // Start listening 158 recognition.start(); 159 recognition.onresult = (event) => { 160 let parsed_responce = "" 161 for (var result of event.results[0]) { 162 parsed_responce += `${result.transcript}, `; 163 parsed_responce += `${result.transcript.split(" or ").join(", ")}, `; 164 } 165 this.responce = parsed_responce; 166 this.Submit(); 167 }; 168 } 169 }, 170 171 /** 172 * Process the user's responce 173 */ 174 Submit: function() { 175 // Parse responce 176 let responce = this.responce.toLowerCase(); // Make responce lowercase 177 for (let shortcut in this.shortcuts) { 178 responce = responce.replace(this.shortcuts[shortcut], shortcut); 179 } 180 let responces = responce.split(","); // Split string by commas 181 for (let i = 0; i < responces.length; i++) { 182 responces[i] = responces[i].trim(); // Trim whitespace 183 } 184 185 // Parse answer 186 let answers = this.prompt[3].toLowerCase().split(","); // Split string by commas 187 for (let i = 0; i < answers.length; i++) { 188 answers[i] = answers[i].trim(); // Trim whitespace 189 } 190 191 // Count correct responces 192 let correctResponces = 0; 193 for (let answer of answers) { 194 if (responces.includes(answer)) { 195 correctResponces++; 196 } 197 } 198 199 // Determine if responce is correct (and enforce multipleAnswers setting) 200 let correct; 201 if (this.settings.multipleAnswers === "Require all") { 202 correct = correctResponces === answers.length; 203 } 204 else { 205 correct = correctResponces > 0; 206 } 207 208 // Give user feedback 209 if (!correct && (this.settings.onMissedPrompt === "Correct me" || this.settings.onMissedPrompt === "Tell me")) { 210 // Show and hide elements 211 this.responceActive = false; 212 try { 213 // Will fail if not mounted 214 this.$refs.feedback.scrollIntoView(false); 215 } 216 catch { } 217 } 218 else if (!correct && this.settings.onMissedPrompt === "Ignore it") { 219 this.Continue(); 220 } 221 else { 222 // Responce was correct 223 this.Reset(); 224 } 225 }, 226 227 /** 228 * Process an incorrect responce and then reset the quizzer 229 */ 230 Continue: function() { 231 // Repeat prompt 232 switch (this.settings.repeatPrompts) 233 { 234 case "Never": 235 break; 236 case "Immediately": 237 this.index--; 238 break; 239 case "5 prompts later": 240 var temp = this.prompt; 241 this.prompts.splice(this.index, 1); 242 this.prompts.splice(this.index + 5, 0, temp); 243 this.index--; 244 break; 245 case "5 & 10 prompts later": 246 var temp = this.prompt; 247 this.prompts.splice(this.index, 1); 248 this.prompts.splice(this.index + 10, 0, temp); 249 this.prompts.splice(this.index + 5, 0, temp); 250 this.index--; 251 break; 252 case "At the end": 253 var temp = this.prompt; 254 this.prompts.splice(this.index, 1); 255 this.prompts.push(temp); 256 this.index--; 257 break; 258 } 259 260 // Reset quizzer 261 this.Reset(); 262 }, 263 264 /** 265 * Calls Submit or Continue depending on the value of responceActive 266 */ 267 Enter: function() { 268 if (this.responceActive) { 269 this.Submit(); 270 } 271 else { 272 this.Continue(); 273 } 274 }, 275 276 /** 277 * Get the language code that matches a label 278 * @param {String} label - The label 279 * @returns {String} - The language code ("en", "es", etc.) 280 */ 281 getLang: function(label) { 282 if (label.toLowerCase().includes("english") || label.toLowerCase().includes("type") || label.toLowerCase().includes("category")) { 283 return "en"; 284 } 285 else { 286 return "es"; 287 } 288 }, 289 290 /** 291 * Read a peice of text 292 * @param {String} text - The text to read 293 * @param {String} label - The language of the text 294 */ 295 Read: function(text, label) 296 { 297 var msg = new SpeechSynthesisUtterance(text); 298 msg.lang = this.getLang(label); 299 window.speechSynthesis.speak(msg); 300 }, 301 }, 302 303 created: function() { 304 // Add keyup handler 305 window.addEventListener("keyup", this.keyup); 306 307 // Update prompts 308 this.prompts = this.startingPrompts; 309 this.index = this.startingIndex - 1; 310 311 // Reset quizzer 312 this.Reset(); 313 }, 314 315 destroyed: function() { 316 // Remove keyup handler 317 window.removeEventListener("keyup", this.keyup); 318 }, 319 320 directives: { 321 focus: { 322 inserted: function (el) { 323 el.focus(); 324 } 325 } 326 }, 327 328 template: ` 329 <div> 330 <div class="quizzer" v-show="index < prompts.length"> 331 <p class="quizzerProgress">{{ index }} / {{ prompts.length }}</p> 332 333 <div class="quizzerPrompt"> 334 <label for="quizzerPrompt"> 335 {{ prompt[0] }} 336 </label> 337 <span id="quizzerPrompt" :lang="getLang(prompt[0])" @click="Read(prompt[1], prompt[0]);"> 338 {{ settings.promptType === "Audio" ? "Click to hear again" : prompt[1] }} 339 </span> 340 <button class="icon" title="Read prompt" @click="$event.target.parentElement.blur(); Read(prompt[1], prompt[0]);"> 341 <img alt="" src="images/sound.svg"> 342 </button> 343 </div> 344 345 <label class="quizzerInputLabel" for="quizzerInput">{{ prompt[2] }}</label> 346 347 <div class="quizzerInput"> 348 <input ref="input" id="quizzerInput" type="text" v-model="responce" :readonly="settings.inputType === 'Voice'" v-if="responceActive" 349 :lang="getLang(prompt[2])" autocomplete="off" spellcheck="false" autocorrect="off" placeholder="Type the answer" v-focus> 350 <div v-show="!responceActive"> 351 <span v-for="part in diff.input"> 352 <del v-if="part.changed">{{ part.value }}</del><span v-if="!part.changed">{{ part.value }}</span> 353 </span> 354 </div> 355 </div> 356 357 <div class="quizzerButtons"> 358 <button v-if="responceActive" :disabled="settings.inputType === 'Voice'" @click="Submit();">Submit</button> 359 <button v-else @click="Continue();">Continue</button> 360 <button @click="Reset();">Skip</button> 361 </div> 362 363 <div class="quizzerFeedback" ref="feedback" v-show="!responceActive"> 364 <span v-if="settings.onMissedPrompt === 'Correct me'"> 365 The correct answer is 366 <span class="quizzerFeedbackTerm" @click="Read(prompt[3], prompt[2]);"> 367 <span v-for="part in diff.answer"> 368 <ins v-if="part.changed">{{ part.value }}</ins><span v-if="!part.changed">{{ part.value }}</span> 369 </span> 370 </span> 371 <button class="icon" title="Read answer" @click="$event.target.parentElement.blur(); Read(prompt[3], prompt[2]);"> 372 <img alt="" src="images/sound.svg"> 373 </button> 374 </span> 375 <span v-if="settings.onMissedPrompt === 'Tell me'"> 376 Incorrect. 377 </span> 378 </div> 379 </div> 380 381 <div class="congrats" v-show="index >= prompts.length"> 382 <p>Congratulations, You finished all of the prompts!</p> 383 <button @click="$emit('finished-prompts')">Continue</button> 384 </div> 385 </div> 386 `, 387 }); 388 389 390 391 // quizzer-page component 392 const quizzerPage = Vue.component("quizzerPage", { 393 props: { 394 "referer": { 395 type: String, 396 default: "home", 397 }, 398 "startingPrompts": { 399 type: Array 400 }, 401 "startingIndex": { 402 type: Number 403 }, 404 "settings": { 405 type: Object 406 } 407 }, 408 409 data: function() { 410 return { 411 prompts: this.startingPrompts, 412 index: this.startingIndex, 413 } 414 }, 415 416 methods: { 417 /** 418 * Update the user's progress in localStorage. 419 * @param {Array} prompts - The list of prompts. 420 * @param {Number} index - The index of the current prompt. 421 */ 422 updateProgress: function(prompts, index) { 423 // Save progress 424 localStorage.setItem("last-session", JSON.stringify({ prompts: prompts, index: index })); 425 } 426 }, 427 428 created: function() { 429 // Try to resume session if props are missing 430 if (this.prompts == undefined || this.index == undefined) { 431 try { 432 // Get last session 433 let { prompts, index } = JSON.parse(localStorage.getItem("last-session")); 434 435 // Validate prompts and promptIndex 436 if (prompts && !isNaN(index) && index >= 0 && index < prompts.length) { 437 this.prompts = prompts; 438 this.index = index; 439 } 440 } catch {} 441 } 442 443 // Go back if props are missing 444 if (this.prompts == undefined || this.index == undefined) { 445 alert("Unable to resume the previous session"); 446 this.$emit("back", this.referer); 447 } 448 }, 449 450 template: ` 451 <div class="quizzer-page"> 452 <page-header @click1="$emit('back', referer);" icon1="x" label1="Back"></page-header> 453 <main> 454 <quizzer :starting-prompts="prompts" :starting-index="index" :settings="settings" 455 @new-prompt="updateProgress" @finished-prompts="$emit('back', referer);"> 456 </quizzer> 457 </main> 458 </div> 459 `, 460 });