Here are some code samples that should help with making cards look and function uniformly across all the Anki platforms. This includes:
- Typing scripts with typed error highlighting (reference issue)
- AnkiWeb audio buttons (can also be used as a base for custom audio with controllable playback: reference issue)
- AnkiWeb TTS (reference issue)
- AnkiWeb and AnkiMobile css resets to avoid unexpected styling behavior (reference issue)
Full code for each feature below:
Typing code
Based on the solution found in this thread. Tested with the new AnkiDroid study screen. The setup requires several components:
- Replacing
{{type:Field}}with
(on the backside, it is enough to leave just<input id="typeans" type="text" inputmode="text" autocorrect="off" autocomplete="off" autocapitalize="off" spellcheck="false"><input id="typeans">for brevity) and adding<data id="expans">{{Field}}</data>anywhere on the front side of the card. This is the element that will indicate which field contains the expected typed answer (can be modified to make the answer composed out of multiple fields and plain text pieces) - Appending the front-side typing script to the front template:
<script> // cross-platform typing if (!window.answer) { // prevent execution on the back side // parsing and saving expected answer const expAnsL = document.getElementById('expans'); const expAns = expAnsL?.innerText.trim(); sessionStorage.setItem("card::expectedAnswer", expAnsL?.innerText || ""); // saving typed answer const typeAnsL = document.getElementById('typeans'); function storeAnswer(typeAns = "") { if (!typeAns && typeAnsL) {typeAns = typeAnsL.value}; sessionStorage.setItem("card::typedAnswer", typeAns); } storeAnswer(); // clean up the storage after previous card sessionStorage.setItem("card::input", "typing"); // saving the front-side input method function ShowAnswer() { if (!!window.pycmd) { // desktop pycmd("ans"); } else if (!!window.showAnswer) { // AnkiDroid showAnswer(); } else if (!!window.qa_box) { // AnkiWeb document.querySelector('.btn.btn-primary.btn-lg').click(); } } if (typeAnsL) { typeAnsL.addEventListener('input', (ev) => { storeAnswer(); }); typeAnsL.addEventListener('keydown', (ev) => { if (event.key === "Enter") { ShowAnswer(); } }); if (!!window.qa_box) { // AnkiWeb setTimeout( () => { // re-focus the typing field document.activeElement.blur(); typeAnsL.focus(); }, 100); } } } </script> - Adding
<hr id=answer>to the back side of the card (if it’s not there already; not necessary if the backside does not use the{{FrontSide}}replacement) and appending the back-side typing script:<script> // cross-platform typed answer diff (() => { // prevent ankiweb from immediately rating a card flipped by Enter if (!!window.qa_box && !window.awRefocus) { const btnAreaL = document.getElementById("ansarea"); window.awRefocus = new MutationObserver(() => { btnAreaL.querySelectorAll('[autofocus]').forEach(L => { L.removeAttribute('autofocus'); L.blur(); }); setTimeout(() => { const rateButtonLs = btnAreaL.querySelectorAll('.btn.btn-primary.btn-lg'); if (rateButtonLs.length === 4) { rateButtonLs[2].focus(); // refocus 'good' button } }, 0); }); window.awRefocus.observe(btnAreaL, { childList: true, subtree: true }); }; // retrieving and parsing expected answer const expAns = sessionStorage.getItem("card::expectedAnswer")?.trim() || ""; // retrieving typed answer const typeAns = sessionStorage.getItem("card::typedAnswer")?.trim() || ""; function htmlEscape(string) { return string.replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll("'", ''') .replaceAll('"', '"'); } function stringDiff2(s1, s2) { const n = s1.length; const m = s2.length; // Matrix of common subsequences' lengths const M = Array.from({length: n + 1}, () => Array(m + 1).fill(0)); // init for (let i = 0; i < n; i++) { for (let j = 0; j < m; j++) { if (s1[i] === s2[j]) { M[i+1][j+1] = M[i][j] + 1; } } } // longest common subsequence in a given minor of M function minorLCS( [n1, n2], [m1, m2] ) { let max = 0; let pos; for (let i = n1; i <= n2; i++) { for (let j = m1; j <= m2; j++) { const Mij = Math.min(M[i][j], j - m1 + 1, i - n1 + 1); if (Mij > max) { max = Mij; pos = [i, j]; } } } return { "length": max, pos }; } // Ratcliff-Obershelp implementation function Diff2( [n1, n2], [m1, m2] ) { const LCS = minorLCS( [n1, n2], [m1, m2] ); const length0 = LCS.length; if (length0 === 0) { // purely different part (base case) let diffTyped = s1.substring(n1 - 1, n2); let diffExpected = s2.substring(m1 - 1, m2); if (diffTyped) { diffTyped = '<span class="typeBad">' + htmlEscape(diffTyped) + '</span>'; } else if (diffExpected) { diffTyped = '<span class="typeMissed">' + "-".repeat(diffExpected.length) + '</span>'; } if (diffExpected) { diffExpected = '<span class="typeMissed">' + htmlEscape(diffExpected) + '</span>'; } return { diffTyped, diffExpected }; } else { // recursive case const [n0, m0] = LCS.pos; const D1 = Diff2( [n1, n0-length0], [m1, m0-length0] ); // recursive calls const D2 = Diff2( [n0+1, n2], [m0+1, m2] ); const diffCommon = '<span class="typeGood">' + htmlEscape(s1.substring(n0 - length0, n0)) + '</span>'; // common part return { diffTyped: D1.diffTyped + diffCommon + D2.diffTyped, diffExpected: D1.diffExpected + diffCommon + D2.diffExpected } } } return Diff2([1, n], [1, m]); // full diff } // displaying the diff const typeAnsL = document.getElementById('typeans'); if (typeAns === expAns) { // correct typed answer typeAnsL.outerHTML = '<code id="typeans">' + '<span class="typeGood">' + expAns + '</span></code>'; } else if (typeAns) { // incorrect typed answer const ansDiff = stringDiff2(typeAns, expAns); typeAnsL.outerHTML = '<code id="typeans">' + ansDiff.diffTyped + '<br><span id="typearrow">↓</span><br>' + ansDiff.diffExpected + '</code>'; } else { // no typed answer typeAnsL.outerHTML = '<code id="typeans">' + expAns + '</code>'; } })(); </script> - Adding the styles for the typing and answer comparison elements
data { display: none; } #typeans { width: 100%; box-sizing: border-box; line-height: 1.75; font-size: 20px; /* element.style */ } code#typeans { white-space: pre-wrap; font-variant-ligatures: none; } .typeGood { background: #afa; color: #000 } .typeBad { color: #000; background: #faa; } .typeMissed { color: #000; background: #ccc; }
AnkiWeb Audio and TTS buttons
The code should be appended to each side of each card that uses either audio fields (containing [sound: ...]) or text to speech tags (if the backside uses {{FrontSide}} replacement, putting the code on the frontside is enough to cover both sides of the card). The TTS solution should be cleaner than the previously published one, as it no longer rebuilds the whole DOM when performing the replacements, keeping all JS bindings that might be inserted by other card scripts intact.
<script>
// AnkiWeb [sound:] and TTS
(()=>{
function createAudioButton(onclickFunction) {
const btnL = document.createElement("a");
btnL.classList.add("replay-button", "soundLink");
btnL.setAttribute('draggable', false);
btnL.innerHTML = `<svg class="playImage" viewBox="0 0 64 64" version="1.1" width="40px" height="40px"><circle cx="32" cy="32" r="29" fill="#fff" stroke="#414141"></circle><path fill="#414141" d="M56.502,32.301l-37.502,20.101l0.329,-40.804l37.173,20.703Z"></path></svg>`;
btnL.href = "../#";
btnL.onclick = onclickFunction;
return btnL;
}
document.querySelectorAll("audio").forEach(audioL => {
audioL.parentNode.replaceChild(createAudioButton(() => audioL.play()), audioL);
});
// AnkiWeb TTS
const qaL = document.querySelector('#qa_box #qa');
if (!qaL) return;
const TTSRegex = /\[anki:tts([^\]]*)\]([^\[]*)\[\/anki:tts\]/g;
function parseTTSAttrs(attr_string) {
const attrs = {};
attr_string.trim().split(/\s+/).forEach(attr => {
const [key, value] = attr.split('=');
if (key && value) attrs[key] = value;
});
return attrs;
}
const walker = document.createTreeWalker(qaL, NodeFilter.SHOW_TEXT, null);
const textNodes = [];
while (walker.nextNode()) {
textNodes.push(walker.currentNode);
}
for (const textNode of textNodes) {
const text = textNode.nodeValue;
let lastIndex = 0, match;
const frag = document.createDocumentFragment();
TTSRegex.lastIndex = 0;
while ((match = TTSRegex.exec(text))) {
if (match.index > lastIndex) {
frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
}
const attrs = parseTTSAttrs(match[1]);
const word = match[2];
frag.appendChild(createAudioButton(()=>{
const ut = new SpeechSynthesisUtterance(word);
ut.lang = attrs["lang"]?.replace('_','-') || "en-US";
window.speechSynthesis.speak(ut);
}));
lastIndex = TTSRegex.lastIndex;
}
if (lastIndex < text.length) {
frag.appendChild(document.createTextNode(text.slice(lastIndex)));
}
if (frag.childNodes.length) {
textNode.parentNode.replaceChild(frag, textNode);
}
}
})();
</script>
Style resets
Removes rules specific to AnkiWeb and AnkiMobile (based on the code published here) and carries over desktop styling to other plaforms:
body:where(:has(#qa_box)),
:where(#qa_box).card {
background: unset;
width: 100%;
}
#qa_box.card #qa {
width: 100%;
}
html:is(.iphone, ipad) img,
#qa_box.card img {
max-width: 100%;
max-height: 95vh;
}
#qa_box.card :is(img, svg) {
vertical-align: baseline;
}
#qa_box.card code {
color: inherit;
font-size: 1em;
font-family: courier, monospace;
}
#qa_box.card hr {
opacity: 1;
}
a.replay-button.soundLink {
display: inline-flex;
}
html:is(.iphone, ipad) body {
text-align: start;
}
/* desktop reviewer css */
hr {
background-color: #737373;
margin: 1em 0;
border: none;
height: 1px;
}
