- 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.
330 lines
10 KiB
JavaScript
330 lines
10 KiB
JavaScript
(function() {
|
|
const FC = {
|
|
currentSet: [],
|
|
cards: [],
|
|
curCardEls: [], // array of card HTMLElements
|
|
nextCardIdx: 0,
|
|
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 */
|
|
var currentIndex = inArray.length
|
|
, temporaryValue
|
|
, randomIndex
|
|
;
|
|
|
|
var outArray = inArray.slice(0);
|
|
|
|
// While there remain elements to shuffle...
|
|
while (0 !== currentIndex) {
|
|
|
|
// Pick a remaining element...
|
|
randomIndex = Math.floor(Math.random() * currentIndex);
|
|
currentIndex -= 1;
|
|
|
|
// And swap it with the current element.
|
|
temporaryValue = outArray[currentIndex];
|
|
outArray[currentIndex] = outArray[randomIndex];
|
|
outArray[randomIndex] = temporaryValue;
|
|
}
|
|
|
|
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');
|
|
};
|
|
|
|
FC.toggleAdvSettings = function(ev) {
|
|
FC.settingsEl.classList.toggle('adv-settings-visible');
|
|
};
|
|
|
|
FC.makeSetFromSettings = function(ev) {
|
|
if (ev && ev.preventDefault) ev.preventDefault();
|
|
|
|
const newSet = {
|
|
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'
|
|
};
|
|
|
|
return newSet;
|
|
};
|
|
|
|
FC.populateSettingsFromSet = function(set) {
|
|
FC.$('input[name=saveSetName]').value = set.name;
|
|
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) {
|
|
/* Handler for the "Go!" button that starts the flashcard display. */
|
|
|
|
ev.preventDefault();
|
|
|
|
FC.currentSet = FC.makeSetFromSettings();
|
|
FC.cards = FC.zipItems(
|
|
FC.currentSet.prompts.split('\n'),
|
|
FC.currentSet.answers?.split('\n')
|
|
);
|
|
|
|
FC.curCardEls = FC.cards.map(FC.makeCard);
|
|
|
|
FC.curCardEls.forEach(cardEl => FC.cardsEl.prepend(cardEl));
|
|
|
|
FC.nextCardIdx = 0;
|
|
|
|
const orderedIndices = [...Array(FC.cards.length).keys()];
|
|
switch (FC.currentSet.sortOrder) {
|
|
default:
|
|
case 'in-order':
|
|
FC.cardOrder = orderedIndices;
|
|
break;
|
|
case 'reverse':
|
|
FC.cardOrder = orderedIndices.reverse();
|
|
break;
|
|
case 'random':
|
|
FC.cardOrder = FC.shuffle(orderedIndices);
|
|
}
|
|
|
|
FC.$('html').requestFullscreen();
|
|
FC.showNextCard();
|
|
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 '<img src="' + content.trim() + '"/>';
|
|
} 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() {
|
|
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) {
|
|
ev.preventDefault();
|
|
|
|
const setName = FC.$('select[name=loadSetName]').value;
|
|
if (setName === '_NULL_') {
|
|
alert("Can't load: no set selected.");
|
|
return;
|
|
}
|
|
|
|
const setToLoad = FC.savedSets.find(item => item.name === setName);
|
|
FC.populateSettingsFromSet(setToLoad);
|
|
};
|
|
|
|
FC.handleDeleteSet = function(ev) {
|
|
if (ev) ev.preventDefault();
|
|
|
|
const setName = FC.$('select[name=loadSetName]').value;
|
|
if (setName === '_NULL_') {
|
|
alert("Can't delete: no set selected.");
|
|
return;
|
|
}
|
|
|
|
FC.savedSets = FC.savedSets.filter(set => set.name !== setName);
|
|
FC.storeSets(FC.savedSets);
|
|
FC.updateSetList(FC.savedSets);
|
|
}
|
|
|
|
FC.handleSaveSet = function(ev) {
|
|
if (ev) ev.preventDefault();
|
|
|
|
const newSet = FC.makeSetFromSettings();
|
|
FC.saveSet(newSet);
|
|
}
|
|
|
|
FC.saveSet = function(newSet) {
|
|
// Look for an existing set with this name
|
|
const existingIdx = FC.savedSets.findIndex(set => set.name === newSet.name);
|
|
|
|
if (existingIdx < 0) FC.savedSets.push(newSet);
|
|
else FC.savedSets[existingIdx] = newSet;
|
|
|
|
FC.storeSets(FC.savedSets);
|
|
FC.updateSetList(FC.savedSets);
|
|
};
|
|
|
|
FC.updateSetList = function(sets) {
|
|
const setSelectEl = FC.$('select[name=loadSetName]');
|
|
var newHtml = '<option value=_NULL_>choose a set to load</option>';
|
|
|
|
newHtml += sets.map(set => '<option>' + set.name + '</option>');
|
|
setSelectEl.innerHTML = newHtml;
|
|
}
|
|
|
|
FC.storeSets = function(cardSets) {
|
|
window.localStorage.setItem('cardSets', JSON.stringify(cardSets));
|
|
};
|
|
|
|
FC.retrieveSets = function() {
|
|
return JSON.parse(window.localStorage.getItem('cardSets') || "[]");
|
|
};
|
|
|
|
FC.exportCardSet = function(ev) {
|
|
ev.preventDefault();
|
|
|
|
const setToExport = FC.makeSetFromSettings();
|
|
const blob = new Blob(
|
|
[JSON.stringify(setToExport)],
|
|
{type: 'text/json;charset=utf-8', endings: 'native' });
|
|
|
|
const downloadLink = document.createElement('a');
|
|
downloadLink.href = window.URL.createObjectURL(blob);
|
|
downloadLink.download = setToExport.name + '.json';
|
|
downloadLink.style.display = 'none';
|
|
document.body.appendChild(downloadLink);
|
|
downloadLink.click();
|
|
document.body.removeChild(downloadLink);
|
|
};
|
|
|
|
FC.importCardSet = function(ev) {
|
|
ev.preventDefault();
|
|
const fileInput = FC.$('input[name=importFileName]');
|
|
fileInput.click();
|
|
};
|
|
|
|
FC.handleImport = function(ev) {
|
|
if (this.files.length === 0 || !this.files[0]) return;
|
|
|
|
const fileToImport = this.files[0];
|
|
console.log("Importing file: ", fileToImport);
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = function(readEv) {
|
|
const importedSet = JSON.parse(readEv.target.result);
|
|
FC.saveSet(importedSet);
|
|
FC.storeSets(FC.savedSets);
|
|
FC.updateSetList(FC.savedSets);
|
|
FC.populateSettingsFromSet(importedSet);
|
|
};
|
|
|
|
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.promptsInputEl = FC.$('#prompts-input');
|
|
FC.answersInputEl = FC.$('#answers-input');
|
|
FC.bodyEl = FC.$('body');
|
|
FC.cardsEl = FC.$('#cards');
|
|
|
|
// Event handlers
|
|
FC.$('.toggle-adv-settings').addEventListener('click', FC.toggleAdvSettings);
|
|
FC.$('#start-button').addEventListener('click', FC.startCards);
|
|
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);
|
|
FC.$('#delete-set').addEventListener('click', FC.handleDeleteSet);
|
|
|
|
// Load saved flashcard lists
|
|
FC.savedSets = FC.retrieveSets();
|
|
FC.updateSetList(FC.savedSets);
|
|
|
|
};
|
|
|
|
window.FC = FC;
|
|
})();
|