From 3b62968dd76549ce4debe207c2d8cce16974a205 Mon Sep 17 00:00:00 2001 From: Jonathan Bernard Date: Fri, 11 Dec 2020 15:25:31 -0600 Subject: [PATCH] v2.0 - Images, answers, and imports. - Answers: flashcards now have the concept of prompts and answers. If answers are enabled, the prompt is shown and then the answer is shown below. Timeouts for both are configurable. - Images: flashcard prompts and answers can be URLs to images that will be loaded and shown. - Imports: adds support for loading JSON card set definitions from URLs. - There is also an architectural change to the slide rendering. Previously we re-used the same div for all cards, rendering the new card content into that div when we wanted to show the card. Now all cards have their own div, all rendered when the user starts. CSS is used to hide all except the current card. This is an optimization to prevent lag during card transition due to DOM updates or image loading. For images specifically, this method causes all images to be fetched immediately and kept in memory. --- flashcards.css | 73 +++++++++++++++++++++++++--- flashcards.html | 29 +++++++++-- flashcards.js | 126 ++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 195 insertions(+), 33 deletions(-) diff --git a/flashcards.css b/flashcards.css index d2ea3b4..54d275d 100644 --- a/flashcards.css +++ b/flashcards.css @@ -97,6 +97,13 @@ button { min-width: 12em; } +#adv-settings label input[type="radio"] { + min-width: unset; + margin: 0 0.25em 0 1.5em; +} + +#adv-settings label input[type="radio"]:first-of-type { margin-left: 0; } + #adv-settings button { margin: 0 0.5em; } @@ -111,12 +118,58 @@ input[name=importFileName] { flex-direction: column; flex-grow: 1; justify-content: space-around; + } -#card-content { margin: auto; } -#card-content.small-text{ font-size: 100%; } -#card-content.medium-text{ font-size: 200%; } -#card-content.large-text{ font-size: 800%; } +#card-input { + flex-grow: 2; + display: flex; + flex-direction: row; +} + +#card-input label { + display: flex; + flex-direction: column; + align-items: stretch; + flex-grow: 1; + margin: 0 0.5em; +} + +.card { + display: none; + flex-direction: column; + align-items: center; + width: 100%; +} + +.card.current { display: flex; } + +.card div { + display: flex; + flex-direction: column; + align-items: center; +} + +.card img { + height: auto; + max-height: 100%; + max-width: 100%; +} + +.answer { opacity: 0; } +.card.show-answer .answer { opacity: 1; } + +.card.small img { width: 50%; } +.card.medium img { width: 75%; } +.card.large img { width: 100%; } + +.card.small .prompt { font-size: 100%; } +.card.medium .prompt { font-size: 200%; } +.card.large .prompt { font-size: 800%; } + +.card.small .answer { font-size: 75%; } +.card.medium .answer { font-size: 150%; } +.card.large .answer { font-size: 400%; } /* Card View Visible */ #settings { @@ -132,6 +185,14 @@ input[name=importFileName] { display: none; } +#card-input label.only-with-answers, +#adv-settings label.only-with-answers +{ display: none; } + +.cards-have-answers #card-input label.only-with-answers, +.cards-have-answers #adv-settings label.only-with-answers +{ display: flex; } + /* Mobile View */ @media ( max-width: 600px ) { body { @@ -162,7 +223,7 @@ input[name=importFileName] { height: calc(100vh - 2em); width: calc(100vw - 2em); } - + #settings > button, #cards > button { font-size: 125%; margin: 1em auto; @@ -194,5 +255,3 @@ input[name=importFileName] { bottom: 0; right: 0; } - - diff --git a/flashcards.html b/flashcards.html index f2a62f1..05407ce 100644 --- a/flashcards.html +++ b/flashcards.html @@ -13,15 +13,30 @@

Simple Flashcards

- - +
+ + +
+ + +
-
diff --git a/flashcards.js b/flashcards.js index 3082132..7c94530 100644 --- a/flashcards.js +++ b/flashcards.js @@ -1,14 +1,18 @@ (function() { const FC = { currentSet: [], - items: [], + cards: [], + curCardEls: [], // array of card HTMLElements nextCardIdx: 0, - cardOrder: [], // elements are objects: { name: 'abc', cards: 'xyz' } - savedSets: [], + cardOrder: [], + savedSets: [], // elements are objects: { name: 'abc', cards: 'xyz' } $: document.querySelector.bind(document), version: "%VERSION%" }; + const IMG_REGEX = /(http|https|file):\/\/(.*)\.(jpg|jpeg|gif|png|svg)/; + const URL_REGEX = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:www\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/; + FC.shuffle = function(inArray) { /* http://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array * modified to be a pure function, not mutating it's input */ @@ -35,6 +39,10 @@ return outArray; }; + FC.zipItems = function(prompts, answers) { + return prompts.map((prompt, idx) => ({ prompt, answer: answers[idx] || null })); + } + FC.isRunning = function() { return !FC.bodyEl.classList.contains('settings-visible'); }; @@ -47,11 +55,12 @@ if (ev && ev.preventDefault) ev.preventDefault(); const newSet = { - name: (FC.currentSet || {}).name || - FC.$('input[name=saveSetName]').value || - 'new set', - cards: FC.itemsEl.value, - cardPeriod: parseInt(FC.$('input[name=cardPeriod]').value) || 3, + name: FC.$('input[name=saveSetName]').value || 'new set', + prompts: FC.promptsInputEl.value, + answers: FC.answersInputEl.value, + cardsHaveAnswers: FC.$('input[name=hasAnswers]:checked')?.value === 'yes', + promptPeriod: parseInt(FC.$('input[name=promptPeriod]').value) || 3, + answerPeriod: parseInt(FC.$('input[name=answerPeriod]').value) || 3, textSize: FC.$('select[name=textSize]').value || 'small', sortOrder: FC.$('select[name=cardOrder]').value || 'in-order' }; @@ -61,10 +70,15 @@ FC.populateSettingsFromSet = function(set) { FC.$('input[name=saveSetName]').value = set.name; - FC.itemsEl.value = set.cards; - FC.$('input[name=cardPeriod]').value = set.cardPeriod; + FC.promptsInputEl.value = set.prompts; + FC.answersInputEl.value = set.answers; + FC.$('input[name=promptPeriod]').value = set.promptPeriod; + FC.$('input[name=answerPeriod]').value = set.answerPeriod; FC.$('select[name=cardOrder]').value = set.sortOrder; FC.$('select[name=textSize]').value = set.textSize; + FC.$('input[name=hasAnswers][value="' + + (set.cardsHaveAnswers?'yes':'no') + '"]').checked = true; + FC.setCardsHaveAnswers(); }; FC.startCards = function(ev) { @@ -73,15 +87,18 @@ ev.preventDefault(); FC.currentSet = FC.makeSetFromSettings(); - FC.items = FC.currentSet.cards.split('\n'); + FC.cards = FC.zipItems( + FC.currentSet.prompts.split('\n'), + FC.currentSet.answers?.split('\n') + ); - FC.cardContentEl.classList.remove( - 'small-text', 'medium-text', 'large-text'); - FC.cardContentEl.classList.add(FC.currentSet.textSize + '-text'); + FC.curCardEls = FC.cards.map(FC.makeCard); + + FC.curCardEls.forEach(cardEl => FC.cardsEl.prepend(cardEl)); FC.nextCardIdx = 0; - const orderedIndices = [...Array(FC.items.length).keys()]; + const orderedIndices = [...Array(FC.cards.length).keys()]; switch (FC.currentSet.sortOrder) { default: case 'in-order': @@ -96,20 +113,60 @@ FC.$('html').requestFullscreen(); FC.showNextCard(); - FC.runningInterval = setInterval(FC.showNextCard, FC.currentSet.cardPeriod * 1000); + const fullInterval = (FC.currentSet.promptPeriod * 1000) + + (FC.currentSet.cardsHaveAnswers ? FC.currentSet.answerPeriod * 1000 : 0); + FC.runningInterval = setInterval(FC.showNextCard, fullInterval); FC.bodyEl.classList.remove('settings-visible'); }; + FC.makeCard = function(item, idx) { + const newCardDiv = document.createElement("div"); + newCardDiv.classList.add('card', FC.currentSet.textSize); + newCardDiv.dataset.index = idx; + + const promptDiv = document.createElement("div"); + promptDiv.classList.add('prompt'); + promptDiv.innerHTML = FC.transformContent(item.prompt); + + const answerDiv = document.createElement("div"); + answerDiv.classList.add('answer'); + answerDiv.innerHTML = FC.transformContent(item.answer); + + newCardDiv.appendChild(promptDiv); + newCardDiv.appendChild(answerDiv); + return newCardDiv; + }; + + FC.transformContent = function(content) { + const matchesImg = content.toLowerCase().trim().match(IMG_REGEX); + if (matchesImg) { + return ''; + } else { + return content; + } + }; + FC.stopCards = function(ev) { clearInterval(FC.runningInterval); document.exitFullscreen(); FC.bodyEl.classList.add('settings-visible'); + document.querySelectorAll('.card').forEach(el => el.remove()); }; FC.showNextCard = function() { - const curItem = FC.items[FC.cardOrder[FC.nextCardIdx]]; - FC.cardContentEl.innerHTML = curItem; - FC.nextCardIdx = (FC.nextCardIdx + 1) % FC.items.length; + FC.$('.card.current')?.classList?.remove('current', 'show-answer'); + + const curCardEl = FC.$('.card[data-index="' + FC.cardOrder[FC.nextCardIdx] + '"]'); + curCardEl.classList.add('current'); + + FC.nextCardIdx = (FC.nextCardIdx + 1) % FC.cards.length; + + if (FC.currentSet.cardsHaveAnswers) { + setTimeout( + () => { curCardEl.classList.add('show-answer') }, + FC.currentSet.promptPeriod * 1000 + ); + } }; FC.handleLoadSet = function(ev) { @@ -214,13 +271,37 @@ reader.readAsText(fileToImport); }; + FC.importCardSetFromURL = function(ev) { + fetch(FC.$('input[name=importURL]').value) + .then(resp => resp.json()) + .then(set => { + if (!set.name) throw "Invalid set definition file (missing name)"; + FC.saveSet(set); + FC.storeSets(FC.savedSets); + FC.updateSetList(FC.savedSets); + FC.populateSettingsFromSet(set); + }) + .catch(err => { + alert("Unable to load set from URL: " + err); + console.error(err); + }); + }; + + FC.setCardsHaveAnswers = function(ev) { + if (FC.$('input[name=hasAnswers]:checked')?.value === 'yes') { + FC.bodyEl.classList.add('cards-have-answers'); + } else { + FC.bodyEl.classList.remove('cards-have-answers'); + } + }; + window.onload = function() { // Cached element references FC.settingsEl = FC.$('#settings'); - FC.itemsEl = FC.$('#items-input'); + FC.promptsInputEl = FC.$('#prompts-input'); + FC.answersInputEl = FC.$('#answers-input'); FC.bodyEl = FC.$('body'); FC.cardsEl = FC.$('#cards'); - FC.cardContentEl = FC.$('#card-content'); // Event handlers FC.$('.toggle-adv-settings').addEventListener('click', FC.toggleAdvSettings); @@ -228,8 +309,11 @@ FC.$('#stop-button').addEventListener('click', FC.stopCards); FC.$('#export').addEventListener('click', FC.exportCardSet); FC.$('#import').addEventListener('click', FC.importCardSet); + FC.$('#importURL').addEventListener('click', FC.importCardSetFromURL); FC.$('input[name=importFileName]') .addEventListener('change', FC.handleImport); + document.querySelectorAll('input[name=hasAnswers]').forEach(inpEl => + inpEl.addEventListener('change', FC.setCardsHaveAnswers)); FC.$('#save-set').addEventListener('click', FC.handleSaveSet); FC.$('#load-set').addEventListener('click', FC.handleLoadSet);