spanish-quizzer

An app to quiz you on Spanish vocabulary and verb conjugations
git clone https://git.ashermorgan.net/spanish-quizzer/
Log | Files | Refs | README

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 });