An improved implementation of cloze to enable sequential uncovering

An improved implementation of cloze to enable sequential uncovering. It would be really awesome if all the information was already available on the front.

proposal: All current fields and potential upcoming supported

{{c1::value::hint}}
{{field0::field1::field2::...}}
<span class="cloze">
   <span class="f0">c1</span>
   <span class="f1">value</span>
   <span class="f2">hint</span>
   ...
</span>

Whereby in each case by default all fields not needed are hidden either with CSS stylesheet or attribute ‘style=“display:none;”’.

This allows for easier customizability, even without knowledge of the ANKI background.

You can already do this though. Just use JavaScript arrays.

Ohh. cool, would you teach me how?

For convenience, here’s a finished notetype that supports MathJax clozes as well:

If you’d like to learn JS, this is a fun challenge. You’ll learn about regular expressions, CSS selectors, EventListeners and other important stuff. I can write a guide for a simple solution if you’re interested.

yeah sure, that would be great. Was hoping to accomplish it without a new card type, since I can’t convert all my old card types. I think this is the case for many people. Especially when using existing decks.

So a short tutorial would be really great!

PS: Currently, I am particularly baffled by the template … for the front and back it always states ‘{{cloze:Text}}’, but at one time an output with and at another time an output without a cloze is created.

The approach used for this guide is based on 'Add Notes…' dialogue: replace selected text with (e.g.) all underscores? - #2 by kleinerpirat.

The first step before starting any scripting is to inspect what Anki provides us with.

Data inspection and preparation of HTML


To see the HTML of any Anki webview, use the add-on AnkiWebView Inspector.

As you can see, the hidden words aren’t hard-coded into the HTML:

<span class="cloze">[...]</span>

That means we cannot simply access the word via the element. What a bummer.
But we’re not limited to {{cloze:Text}}, we could also create an element

<div id="rawText">{{Text}}</div>

to get the full sentence without clozes.

We don’t need to see that element though, so we give it the attribute hidden to make it invisible:

<div id="rawText" hidden>{{Text}}</div>

Defining the goal


Now, it’s very important to define the problem we’d like to solve. We got <span> elements that do not contain words, but in the raw text we got {{ci::word}} syntax that does contain the data we need. Our goal is to match each of these words to the corresponding <span>, so we can incrementally reveal them on the front side.

Accessing data


We have prepared our HTML and defined a goal. Now let’s learn about two important ways to access data.

CSS selectors

In JS, you can select HTML elements the same way you would in CSS. The function is called querySelector for a single element and querySelectorAll for all elements. You can call these functions on any HTML element, for example document.

Let’s see how we can store the two elements that contain our data:

var clozeTextElement = document.querySelector("#clozeText");
var rawTextElement = document.querySelector("#rawText");
/**
#foo means: select the element with id "foo".
   alternatively, you can use the function getElementById, e.g.:
   var clozeTextElement = document.getElementById("clozeText");
*/

We can call querySelectorAll on clozeTextElement to get all these <span class="cloze">[...]</span>:

var clozes = clozeTextElement.querySelectorAll("span.cloze");
// get all span elements of class "cloze" within clozeTextElement

Nice. We stored a NodeList of all the cloze spans. Important: This is not an array! You can use the [...list] syntax to convert that list into an array. But how do we access clozes inside the raw text? It isn’t segmented into elements, just a single string:

image

This is where regular expressions come into play.

RegEx (regular expressions)

RegEx searching is performed on the string data type. There are lots of good internet resources to learn RegEx, so let’s not jump into this rabbit hole here. The following syntax will match every cloze with index 1 and store the contained word in a capturing group:

/\{\{c1::([^{]+)}}/g

It won’t work for nested clozes, but since Anki doesn’t support nesting either, we don’t need to worry about that. You can test it out on https://regexr.com/.

Of course, we also want incremental reveals to work for indices other than 1, so we have to get the current card index dynamically. Luckily for us, Anki hard-codes that index into the className of document.body:

image

So we can access that with another RegEx search:

var cardIdx = parseInt(document.body.className.match(/card(\d+)/)[1]);

// [1] is JS syntax to access an array element at index 1
// RegEx matches are arrays where [0] is the whole match and following indices are capturing groups

// parseInt turns a string into the int data type

There are different ways to declare regular expression objects:

var regEx = /\{\{c1::([^{]+)}}/g;

and

var regEx = new RegExp("\{\{c1::([^{]+)}}", "g");

create the same RegExp object. The advantage of the second syntax is that you can use template literals, i.e. you can use variables to create the RegExp dynamically.

Template literals

This is one of the most useful JS features. To appreciate template literals, you need to know how strings had to be concatenated before that special syntax was introduced. Say we want to create a some HTML dynamically with an index i and some text:

// old way
var spanHTML = "<span class=cloze-" + i + ">" + text + "</span>";

// template literal
var spanHTML = `<span class="cloze-${i}">${text}</span>`;

Script

We covered the basics, so you should be able to understand the following script now:

<script>
  setTimeout(() => {
    const cardIdx = parseInt(document.body.className.match(/card(\d+)/)[1]);
    // Array.from allows us to perform a function on each element of an iterable data type,
    const clozeWords = Array.from(
      document
        // get the element containing {{Text}}
        .getElementById("rawText")
        // templated regex search to get all clozes of index cardIdx
        .innerText.matchAll(new RegExp(`\{\{c${cardIdx}::([^{]+)}}`, "g")),
      // store capturing group 1 of each match in the array
      (match) => match[1]
    );
    // set the word as an attribute on each corresponding cloze span
    [...document.getElementsByClassName("cloze")].forEach((cloze, i) => {
      cloze.setAttribute("word", clozeWords[i]);
    });
  });
</script>

(setTimeout delays the function execution in the event loop, which is required to access Anki clozes.)

Resulting HTML

Now the clozed out words are hard-coded into our spans. This means we have the freedom to do whatever we want with that information.


That’s it for today. Stay tuned for part two, where we create a simple script to reveal the words incrementally on keypress. If you want to try it yourself, take a look at Card Templates: User Input 101 (buttons, keyboard shortcuts, etc.) [Guide].

4 Likes

Thank you for accepting the guide above as a solution. Here is how I would implement the incremental reveal:


Let’s say we want to make the incremental reveal happen on Tab key press:

let nextCloze = document.querySelector(".cloze:not(.revealed)");

if (!globalThis.incRevListener) {
  document.addEventListener("keydown", (e) => {
    if (e.code == "Tab" && nextCloze) {
      e.preventDefault();
      nextCloze.classList.add("revealed");
      nextCloze = document.querySelector(".cloze:not(.revealed)");
      if (!nextCloze) {
        flipToBack();
      }
    }
  });
  globalThis.incRevListener = true;
}

(incRevListener is a guard to prevent an accumulation of event listeners on the document, because the script is executed on each card flip.)

The event handler calls a function called flipToBack when it’s time to reveal the last remaining cloze. Here is a slightly modified version from git9527’s function here.

function flipToBack() {
  if (typeof pycmd !== "undefined") {
    pycmd("ans");
  } else if (globalThis.AnkiDroidJS) {
    showAnswer();
  } else if (window.anki && window.sendMessage2) {
    // AnkiMobile: requires setting up midCenter tap to show answer
    window.sendMessage2("ankitap", "midCenter");
  }
}

Now you can do the rest in CSS:

.cloze.revealed {
  width: 0;
  font-size: 0;
}
.cloze.revealed::before {
  font-size: 20px;
  content: attr(word);
}
Full Script
<script>
  setTimeout(() => {
    const cardIdx = parseInt(document.body.className.match(/card(\d+)/)[1]);
    const clozeWords = Array.from(
      document
        .getElementById("rawText")
        .innerText.matchAll(new RegExp(`\{\{c${cardIdx}::([^{]+)}}`, "g")),
      (match) => match[1]
    );
    [...document.getElementsByClassName("cloze")].forEach((cloze, i) => {
      cloze.setAttribute("word", clozeWords[i]);
    });

    let nextCloze = document.querySelector(".cloze:not(.revealed)");

    if (!globalThis.incRevListener) {
      document.addEventListener("keydown", (e) => {
        if (e.code == "Tab" && nextCloze) {
          e.preventDefault();
          nextCloze.classList.add("revealed");
          nextCloze = document.querySelector(".cloze:not(.revealed)");
          if (!nextCloze) {
            flipToBack();
          }
        }
      });
      globalThis.incRevListener = true;
    }
  });

  function flipToBack() {
    if (typeof pycmd !== "undefined") {
      pycmd("ans");
    } else if (globalThis.AnkiDroidJS) {
      showAnswer();
    } else if (window.anki && window.sendMessage2) {
      // AnkiMobile: requires setting up midCenter tap to show answer
      window.sendMessage2("ankitap", "midCenter");
    }
  }
</script>

I used this thread as an opportunity to teach some useful JS skills, but let’s not forget you posted it as a suggestion for the Anki dev team.

As much as I enjoy the dopamine kicks I get from little coding challenges like this, I don’t want user’s templates to get littered with JS workarounds. There are some basic features missing that really should not require hacking. Making the hidden parts of a cloze available natively is low effort and won’t have any negative impact, so I’ll prepare a PR for that.

2 Likes

With great enthusiasm I followed your tutorial, improving my knowledge of JS, CSS, HTML in relation to ANKI and the somewhat questionable representation in the DOM.

Thank you for your extensive and very informative tutorial, which is unparalleled in its scope and comprehensibility for Anki!

I completely agree with you that the current implementation of the text cloze is unsatisfactorily solved and refer to my initial proposal, which is both a simple and significant improvement over the current solution. By simply setting the ‘hidden’ attribute, any field can be shown or hidden.

I’m not sure I understand the notion of fields in your proposal. I guess you don’t mean fields as in “Anki editor fields”, but “database fields”?


I’ve opened a PR that exposes the cloze text as a data-cloze attribute:

This would make the script in part 1 of my guide redundant. Taking the idea of my PR further, we could actually markup every cloze (inactive ones too) the following way:

<!-- Input: {{c1::hidden text::hint}} -->

<cloze data-index="1" data-cloze="hidden text" data-hint="hint" />

Sorry for the late feedback had a very tight schedule.

By ’ fields’ I was referring to the template. This is, after all, just a data schema separated by ‘::’. I simply named the generated data cells after their index position (e.g. f0, f1, …). For the reason that this simplifies a later expansion. Packing the data fields into individual ‘span’ elements served to simplify the manipulation of the displayed content (by setting ´hidden´ or ´display:none´).

I have read your PR and think that this approach will simplify the current situation a bit. Thank you very much. Can hardly wait for the update to reach me.


Likewise I agree, that the cloze should be reworked again and have a couple of ideas.
e.g.

Proposal: A text cloze should be able to be assigned to several groups.

current: {{group::value::hint}}
proposal: {{group1,group2,group3::value::hint}}

Template:

The {{c1,c2::quick}} brown {{c2,c3::fox}} jumps over the lazy dog

Cards:

The [...] brown fox jumps over the lazy dog
The [...] brown [...] jumps over the lazy dog
The quick brown [...] jumps over the lazy dog