Cross-platform Card design

Here are some code samples that should help with making cards look and function uniformly across all the Anki platforms. This includes:

  1. Typing scripts with typed error highlighting (reference issue)
  2. AnkiWeb audio buttons (can also be used as a base for custom audio with controllable playback: reference issue)
  3. AnkiWeb TTS (reference issue)
  4. 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:

  1. Replacing {{type:Field}} with
    <input id="typeans" type="text" inputmode="text" autocorrect="off" autocomplete="off" autocapitalize="off" spellcheck="false">
    
    (on the backside, it is enough to leave just <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)
  2. 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>
    
  3. 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('&', '&amp;')
                     .replaceAll('<', '&lt;')
                     .replaceAll('>', '&gt;')
                     .replaceAll("'", '&#39;')
                     .replaceAll('"', '&quot;');
      }
    
      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>
    
  4. 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;
}
3 Likes