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.
This commit is contained in:
Jonathan Bernard 2020-12-11 15:25:31 -06:00
parent eeebc91723
commit 3b62968dd7
3 changed files with 195 additions and 33 deletions

View File

@ -97,6 +97,13 @@ button {
min-width: 12em; 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 { #adv-settings button {
margin: 0 0.5em; margin: 0 0.5em;
} }
@ -111,12 +118,58 @@ input[name=importFileName] {
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
justify-content: space-around; justify-content: space-around;
} }
#card-content { margin: auto; } #card-input {
#card-content.small-text{ font-size: 100%; } flex-grow: 2;
#card-content.medium-text{ font-size: 200%; } display: flex;
#card-content.large-text{ font-size: 800%; } 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 */ /* Card View Visible */
#settings { #settings {
@ -132,6 +185,14 @@ input[name=importFileName] {
display: none; 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 */ /* Mobile View */
@media ( max-width: 600px ) { @media ( max-width: 600px ) {
body { body {
@ -194,5 +255,3 @@ input[name=importFileName] {
bottom: 0; bottom: 0;
right: 0; right: 0;
} }

View File

@ -13,15 +13,30 @@
<body class=settings-visible> <body class=settings-visible>
<h1 class=title>Simple Flashcards</h1> <h1 class=title>Simple Flashcards</h1>
<form id=settings action=#> <form id=settings action=#>
<label>Items</label> <div id=card-input>
<textarea id=items-input></textarea> <label>Cards
<textarea id=prompts-input></textarea>
</label>
<label class="only-with-answers">Answers
<textarea id=answers-input></textarea>
</label>
</div>
<label class=toggle-adv-settings>Advanced Settings <label class=toggle-adv-settings>Advanced Settings
<span class=visibility-indicator></span> <span class=visibility-indicator></span>
</label> </label>
<div id=adv-settings> <div id=adv-settings>
<label> <label>
<span>Seconds per card:</span> <span>Cards have answers:</span>
<input type=number name=cardPeriod value=3> <label><input type=radio name=hasAnswers value="yes">yes</label>
<label><input type=radio name=hasAnswers value="no" checked>no</label>
</label>
<label>
<span>Seconds per prompt:</span>
<input type=number name=promptPeriod value=3>
</label>
<label class="only-with-answers">
<span>Seconds per answer:</span>
<input type=number name=answerPeriod value=3>
</label> </label>
<label> <label>
<span>Show cards </span> <span>Show cards </span>
@ -57,11 +72,15 @@
<button id=import>Import Cards</button> <button id=import>Import Cards</button>
<input name=importFileName type=file accept='.json'> <input name=importFileName type=file accept='.json'>
</label> </label>
<label>
<span>Import from URL: </span>
<input name=importURL type=text>
<button id=importURL>Import</button>
</label>
</div> </div>
<button class=btn-primary id=start-button>Go!</button> <button class=btn-primary id=start-button>Go!</button>
</form> </form>
<div id=cards> <div id=cards>
<div id=card-content></div>
<button id=stop-button>Stop</button> <button id=stop-button>Stop</button>
</div> </div>
<div id=debug> <div id=debug>

View File

@ -1,14 +1,18 @@
(function() { (function() {
const FC = { const FC = {
currentSet: [], currentSet: [],
items: [], cards: [],
curCardEls: [], // array of card HTMLElements
nextCardIdx: 0, nextCardIdx: 0,
cardOrder: [], // elements are objects: { name: 'abc', cards: 'xyz' } cardOrder: [],
savedSets: [], savedSets: [], // elements are objects: { name: 'abc', cards: 'xyz' }
$: document.querySelector.bind(document), $: document.querySelector.bind(document),
version: "%VERSION%" 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) { FC.shuffle = function(inArray) {
/* http://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array /* http://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array
* modified to be a pure function, not mutating it's input */ * modified to be a pure function, not mutating it's input */
@ -35,6 +39,10 @@
return outArray; return outArray;
}; };
FC.zipItems = function(prompts, answers) {
return prompts.map((prompt, idx) => ({ prompt, answer: answers[idx] || null }));
}
FC.isRunning = function() { FC.isRunning = function() {
return !FC.bodyEl.classList.contains('settings-visible'); return !FC.bodyEl.classList.contains('settings-visible');
}; };
@ -47,11 +55,12 @@
if (ev && ev.preventDefault) ev.preventDefault(); if (ev && ev.preventDefault) ev.preventDefault();
const newSet = { const newSet = {
name: (FC.currentSet || {}).name || name: FC.$('input[name=saveSetName]').value || 'new set',
FC.$('input[name=saveSetName]').value || prompts: FC.promptsInputEl.value,
'new set', answers: FC.answersInputEl.value,
cards: FC.itemsEl.value, cardsHaveAnswers: FC.$('input[name=hasAnswers]:checked')?.value === 'yes',
cardPeriod: parseInt(FC.$('input[name=cardPeriod]').value) || 3, promptPeriod: parseInt(FC.$('input[name=promptPeriod]').value) || 3,
answerPeriod: parseInt(FC.$('input[name=answerPeriod]').value) || 3,
textSize: FC.$('select[name=textSize]').value || 'small', textSize: FC.$('select[name=textSize]').value || 'small',
sortOrder: FC.$('select[name=cardOrder]').value || 'in-order' sortOrder: FC.$('select[name=cardOrder]').value || 'in-order'
}; };
@ -61,10 +70,15 @@
FC.populateSettingsFromSet = function(set) { FC.populateSettingsFromSet = function(set) {
FC.$('input[name=saveSetName]').value = set.name; FC.$('input[name=saveSetName]').value = set.name;
FC.itemsEl.value = set.cards; FC.promptsInputEl.value = set.prompts;
FC.$('input[name=cardPeriod]').value = set.cardPeriod; 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=cardOrder]').value = set.sortOrder;
FC.$('select[name=textSize]').value = set.textSize; FC.$('select[name=textSize]').value = set.textSize;
FC.$('input[name=hasAnswers][value="' +
(set.cardsHaveAnswers?'yes':'no') + '"]').checked = true;
FC.setCardsHaveAnswers();
}; };
FC.startCards = function(ev) { FC.startCards = function(ev) {
@ -73,15 +87,18 @@
ev.preventDefault(); ev.preventDefault();
FC.currentSet = FC.makeSetFromSettings(); 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( FC.curCardEls = FC.cards.map(FC.makeCard);
'small-text', 'medium-text', 'large-text');
FC.cardContentEl.classList.add(FC.currentSet.textSize + '-text'); FC.curCardEls.forEach(cardEl => FC.cardsEl.prepend(cardEl));
FC.nextCardIdx = 0; FC.nextCardIdx = 0;
const orderedIndices = [...Array(FC.items.length).keys()]; const orderedIndices = [...Array(FC.cards.length).keys()];
switch (FC.currentSet.sortOrder) { switch (FC.currentSet.sortOrder) {
default: default:
case 'in-order': case 'in-order':
@ -96,20 +113,60 @@
FC.$('html').requestFullscreen(); FC.$('html').requestFullscreen();
FC.showNextCard(); 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.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) { FC.stopCards = function(ev) {
clearInterval(FC.runningInterval); clearInterval(FC.runningInterval);
document.exitFullscreen(); document.exitFullscreen();
FC.bodyEl.classList.add('settings-visible'); FC.bodyEl.classList.add('settings-visible');
document.querySelectorAll('.card').forEach(el => el.remove());
}; };
FC.showNextCard = function() { FC.showNextCard = function() {
const curItem = FC.items[FC.cardOrder[FC.nextCardIdx]]; FC.$('.card.current')?.classList?.remove('current', 'show-answer');
FC.cardContentEl.innerHTML = curItem;
FC.nextCardIdx = (FC.nextCardIdx + 1) % FC.items.length; 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) { FC.handleLoadSet = function(ev) {
@ -214,13 +271,37 @@
reader.readAsText(fileToImport); 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() { window.onload = function() {
// Cached element references // Cached element references
FC.settingsEl = FC.$('#settings'); FC.settingsEl = FC.$('#settings');
FC.itemsEl = FC.$('#items-input'); FC.promptsInputEl = FC.$('#prompts-input');
FC.answersInputEl = FC.$('#answers-input');
FC.bodyEl = FC.$('body'); FC.bodyEl = FC.$('body');
FC.cardsEl = FC.$('#cards'); FC.cardsEl = FC.$('#cards');
FC.cardContentEl = FC.$('#card-content');
// Event handlers // Event handlers
FC.$('.toggle-adv-settings').addEventListener('click', FC.toggleAdvSettings); FC.$('.toggle-adv-settings').addEventListener('click', FC.toggleAdvSettings);
@ -228,8 +309,11 @@
FC.$('#stop-button').addEventListener('click', FC.stopCards); FC.$('#stop-button').addEventListener('click', FC.stopCards);
FC.$('#export').addEventListener('click', FC.exportCardSet); FC.$('#export').addEventListener('click', FC.exportCardSet);
FC.$('#import').addEventListener('click', FC.importCardSet); FC.$('#import').addEventListener('click', FC.importCardSet);
FC.$('#importURL').addEventListener('click', FC.importCardSetFromURL);
FC.$('input[name=importFileName]') FC.$('input[name=importFileName]')
.addEventListener('change', FC.handleImport); .addEventListener('change', FC.handleImport);
document.querySelectorAll('input[name=hasAnswers]').forEach(inpEl =>
inpEl.addEventListener('change', FC.setCardsHaveAnswers));
FC.$('#save-set').addEventListener('click', FC.handleSaveSet); FC.$('#save-set').addEventListener('click', FC.handleSaveSet);
FC.$('#load-set').addEventListener('click', FC.handleLoadSet); FC.$('#load-set').addEventListener('click', FC.handleLoadSet);