Suggestion: Persistent Incremental Cloze Reveal

So I have this script on me that allows me to hide and unhide cloze brackets while the card is still on the front side without me having to go to the backside to reveal everything at once.

Frontside

<script>
var logDiv = null;
function log(s) {
  if (logDiv == null) {
    logDiv = document.createElement("div");
    logDiv.id = 'log_debug';
    logDiv.style = 'position:absolute;left:0;top:0;z-index:-2;color:grey;font-size:small';
    document.body.append(logDiv);
  }
  logDiv.insertAdjacentHTML('beforeend', s + '<br/>');
}
/*
function logSizes() {
  let ovl = document.getElementById('tap_overlay');
  let rect = ovl.getBoundingClientRect();
  let vp = window.visualViewport;
  log(`ovl: ${rect.width.toFixed(2)},${rect.height.toFixed(2)} vp: ${vp.width.toFixed(2)},${vp.height.toFixed(2)}`);
}
*/


function getCardNumber() {
  clz = document.body.className;
  const regex = /card(\d+)/gm;
  let m;

  if ((m = regex.exec(clz)) !== null) {
    return m[1];
  } else {
    console.error("Cannot find cardN class of body element!");
    return "0";
  }
}

function getClozes(str, cardNumber) {
  const regex = new RegExp(`\{\{c${cardNumber}::(.*?)(\}\}|::.*?\}\})`, 'gm')
  //console.log(regex);
	let m;
  const clozes = [];
	while ((m = regex.exec(str)) !== null) {
		// This is necessary to avoid infinite loops with zero-width matches
		if (m.index === regex.lastIndex) {
			regex.lastIndex++;
		}
		m.forEach((match, groupIndex) => {
			//console.log(`Found match, group ${groupIndex}: ${match}`);
     if (groupIndex == 1) {
				clozes.push(match);
			}
		});
	}
  return clozes;
}

function clickHandler(e){
   //console.log(`${e.target.tagName}(${e.target.id})`);
   const tt = e.target
   if ( tt instanceof HTMLElement &&
       ( tt.id === 'qa' || 
         tt.tagName === 'HTML' ||
         tt.tagName === 'LI' ||
         tt.tagName === 'I' )) {
     //log('reveal');
     revealNextCloze();
   }
}


var elements;
var clozes;
var revealed = [];

function revealCloze(i) {
  if (!revealed[i]) {
    elements[i].innerHTML = clozes[i];
    revealed[i] = true;
  } else if (revealed[i]) {
    elements[i].innerHTML = "[...]";
    revealed[i] = false;
  }
}

function revealNextCloze() {
  firstUnrevealed = revealed.findIndex ( el => !el );
  //log(firstUnrevealed);
  if (firstUnrevealed != -1) {
    revealCloze(firstUnrevealed);
  } 
}

onUpdateHook.push(function() {
  //console.log(`inside update hook`);

	var text = document.getElementById("rawText").innerHTML ;
	//console.log(text);
	clozes = getClozes(text, getCardNumber());
  //console.log(clozes);
	
	elements = document.querySelectorAll(".cloze");

  if (clozes.length != elements.length) {
    console.error("Inconsistent cound of clozes found in original note text and in the card!");
    return;
  }
  elements.forEach((el, i) => {
    el.addEventListener('click', e => {
      revealCloze(i);
      //log(i);
    })
  });
  revealed.length = elements.length;
  revealed.fill(false);
  
  window.addEventListener('click', clickHandler);

});

</script>
<script id="rawText" type="text/plain">
{{Text}}
</script>




Backside

<script>
  window.removeEventListener('click', clickHandler);
</script>
  • What I am trying to achieve is making the revealed state of the brackets remain as they are the next time I see the front side of the card.
  • And of course a button to reset the revealed state at once.

This would save me a lot of pain in learning long sentences and lists.
Suppose a list of items like for the card “c1”:

{{c1::A}}
{{c1::B}}
{{c1::C}}
{{c1::D}}
{{c1::E}}

Clicking on the bracket which has A inside it, reveals the answer upon click. So it becomes like this:

  • A
  • […]
  • […]
  • […]
  • […]

The code I posted does this already. I just want to make it so that the next time I see c1, A remains revealed , or B or C, whatever I originally revealed…and of course a way to reset the reveal back like a button shortcut.
Edit: It seems that this cannot be done without the help of an addon allowing this.

1 Like

These save data to Anki’s card:

1 Like

o3 mini high just came out today, used it and it solved the issue for me. This is one big hunk of code. Great advertisement haha :sweat_smile: :laughing:

Here is the code for everyone interested

Frontside

<script>
// Debug logger (optional)
var logDiv = null;
function log(s) {
  if (logDiv === null) {
    logDiv = document.createElement("div");
    logDiv.id = 'log_debug';
    logDiv.style = 'position:absolute;left:0;top:0;z-index:-2;color:grey;font-size:small';
    document.body.append(logDiv);
  }
  logDiv.insertAdjacentHTML('beforeend', s + '<br/>');
}

// Get the card number from the body class (e.g. "card1", "card2", etc.)
function getCardNumber() {
  var clz = document.body.className;
  const regex = /card(\d+)/gm;
  let m;
  if ((m = regex.exec(clz)) !== null) {
    return m[1];
  } else {
    console.error("Cannot find cardN class of body element!");
    return "0";
  }
}

/*
  Updated getClozes:
  This regex captures:
    - Group 1: The answer text.
    - Group 2: An optional hint (everything until the closing "}}")
  If no hint is provided, the hint defaults to "[...]".
*/
function getClozes(str, cardNumber) {
  const regex = new RegExp(`\\{\\{c${cardNumber}::(.*?)(?:::([^}]*))?\\}\\}`, 'gm');
  let m;
  const clozes = [];
  while ((m = regex.exec(str)) !== null) {
    clozes.push({ 
      answer: m[1].trim(), 
      hint: (typeof m[2] !== "undefined" && m[2].trim() !== "") ? m[2].trim() : "[...]" 
    });
  }
  return clozes;
}

// Global variables holding DOM elements, cloze objects, and their revealed state.
var elements;     // All elements with the class "cloze"
var clozes;       // Array of objects: { answer, hint }
var revealed = [];  // Array of booleans: true means the answer is revealed.

// --- STYLING HELPER FUNCTION ---
// Sets the visual style of a cloze element based on whether it is revealed and on which side.
function styleCloze(el, isRevealed) {
  if (getCardNumber() === "1") {
    // Front side.
    if (!isRevealed) {
      el.style.color = "#BD00FD";
      el.style.backgroundColor = "transparent";
    } else {
      el.style.color = "white";
      el.style.backgroundColor = "black";
    }
  } else {
    // Back side.
    if (isRevealed) {
      el.style.color = "white";
      el.style.backgroundColor = "black";
    } else {
      el.style.color = "gold";
      el.style.backgroundColor = "transparent";
    }
  }
}

// Update the display of all cloze elements according to the revealed array.
function applyRevealedState() {
  elements.forEach((el, i) => {
    if (revealed[i]) {
      el.innerHTML = clozes[i].answer;
    } else {
      el.innerHTML = clozes[i].hint;
    }
    styleCloze(el, revealed[i]);
  });
}

// Save the revealed state to localStorage so that it persists.
function saveRevealedState() {
  localStorage.setItem("cloze_revealed_c" + getCardNumber(), JSON.stringify(revealed));
}

// Load the saved revealed state (if available) or initialize a new state.
function loadRevealedState() {
  let stored = localStorage.getItem("cloze_revealed_c" + getCardNumber());
  if (stored) {
    try {
      let arr = JSON.parse(stored);
      if (Array.isArray(arr) && arr.length === elements.length) {
        return arr;
      }
    } catch(e) {
      console.error("Error parsing saved cloze state:", e);
    }
  }
  return new Array(elements.length).fill(false);
}

// Toggle the state of a single cloze and update the display and storage.
function revealCloze(i) {
  if (!revealed[i]) {
    elements[i].innerHTML = clozes[i].answer;
    revealed[i] = true;
  } else {
    elements[i].innerHTML = clozes[i].hint;
    revealed[i] = false;
  }
  styleCloze(elements[i], revealed[i]);
  saveRevealedState();
}

// Optionally, reveal the first unrevealed cloze.
function revealNextCloze() {
  const firstUnrevealed = revealed.findIndex(el => !el);
  if (firstUnrevealed !== -1) {
    revealCloze(firstUnrevealed);
  }
}

// A click handler to reveal the next cloze if certain elements are clicked.
function clickHandler(e) {
  const tt = e.target;
  if (tt instanceof HTMLElement &&
      (tt.id === 'qa' || tt.tagName === 'HTML' || tt.tagName === 'LI' || tt.tagName === 'I')) {
    revealNextCloze();
  }
}

// Set up everything when the card front is updated.
onUpdateHook.push(function() {
  var cardNumber = getCardNumber();
  var text = document.getElementById("rawText").innerHTML;
  clozes = getClozes(text, cardNumber);
  elements = document.querySelectorAll(".cloze");

  if (clozes.length != elements.length) {
    console.error("Inconsistent count of clozes found in original note text and in the card!");
    return;
  }

  // Attach click listeners to each cloze for individual toggling.
  elements.forEach((el, i) => {
    el.addEventListener('click', e => {
      revealCloze(i);
    });
  });

  // Load any previously saved state and apply it.
  revealed = loadRevealedState();
  applyRevealedState();

  // Attach a global click handler for revealing the next cloze (if desired).
  window.addEventListener('click', clickHandler);

  // Create the buttons.
  createClozeButtons();
});

// Create two buttons:
// 1. Reset Clozes button: Bottom center with a magenta background.
// 2. Reveal All Clozes button: Bottom right with a green background.
function createClozeButtons() {
  // Create Reset Clozes button on bottom center.
  if (!document.getElementById('resetCloze')) {
    var resetBtn = document.createElement('button');
    resetBtn.id = 'resetCloze';
    resetBtn.textContent = 'Reset Clozes';
    resetBtn.style.position = 'fixed';
    resetBtn.style.bottom = '10px';
    resetBtn.style.left = '50%';
    resetBtn.style.transform = 'translateX(-50%)';
    resetBtn.style.zIndex = '9999';
    resetBtn.style.padding = '10px 20px';
    resetBtn.style.backgroundColor = 'magenta';
    resetBtn.style.color = 'white';
    resetBtn.style.border = 'none';
    resetBtn.style.cursor = 'pointer';
    document.body.appendChild(resetBtn);
    resetBtn.addEventListener('click', function() {
      revealed = new Array(elements.length).fill(false);
      applyRevealedState();
      saveRevealedState();
    });
  }
  
  // Create Reveal All Clozes button on bottom right.
  if (!document.getElementById('revealAllClozes')) {
    var revealBtn = document.createElement('button');
    revealBtn.id = 'revealAllClozes';
    revealBtn.textContent = 'Reveal All Clozes';
    revealBtn.style.position = 'fixed';
    revealBtn.style.bottom = '10px';
    revealBtn.style.right = '10px';
    revealBtn.style.zIndex = '9999';
    revealBtn.style.padding = '10px 20px';
    revealBtn.style.backgroundColor = 'green';
    revealBtn.style.color = 'white';
    revealBtn.style.border = 'none';
    revealBtn.style.cursor = 'pointer';
    document.body.appendChild(revealBtn);
    revealBtn.addEventListener('click', function() {
      revealed = new Array(elements.length).fill(true);
      applyRevealedState();
      saveRevealedState();
    });
  }
}

// Attach a keydown listener in the capture phase to reset clozes when the "g" key is pressed.
var resetKeyListenerAdded;
if (!resetKeyListenerAdded) {
  document.addEventListener(
    "keydown",
    function (e) {
      // Do nothing if the user is typing in an input or textarea.
      if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") {
        return;
      }
      if (e.key && e.key.toLowerCase() === "g") {
        console.log("Reset cloze state via keyboard shortcut (g).");
        if (elements && elements.length) {
          revealed = new Array(elements.length).fill(false);
          applyRevealedState();
          saveRevealedState();
          e.preventDefault();
          e.stopPropagation();
        }
      }
    },
    true
  );
  resetKeyListenerAdded= true;
}
</script>

<!-- The raw text is stored here. This should be your note field containing the cloze markers. -->
<script id="rawText" type="text/plain">
{{Text}}
</script>



Backside

<script>
  window.removeEventListener('click', clickHandler);
</script>

PROBLEMS

Problem 1: Keymapping
  • I have just one problem. I made the o3 make a reset cloze button and it works just fine. I tried mapping it to a key and it does not work. I tried mapping it to “g”. It is in the following part of the code. Could you please have a look at it :question:
// Attach a keydown listener in the capture phase to reset clozes when the "h" key is pressed.
document.addEventListener("keydown", function(e) {
  // Do nothing if the user is typing in an input or textarea.
  if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") {
    return;
  }
  if (e.key && e.key.toLowerCase() === "g") {
    if (elements && elements.length) {
      revealed = new Array(elements.length).fill(false);
      applyRevealedState();
      saveRevealedState();
      e.preventDefault();
      e.stopPropagation();
      console.log("Reset cloze state via keyboard shortcut (g).");
    }
  }
}, true);

Problem 2: Nested Cloze - Answer Displayed on Frontside (Regex problem?)
  • EDIT: Second Problem → The code does not work well the nested cloze. It is making the answer appear on the front side fo the card. For a cloze card {{c1::{{c2::A}}}}, c2 is displayed normally (answer is only displayed on the backside, and can be revealed upon click on the front side as per the code).
    For c1 however, the answer is displayed on both the front and the backside. Clicking on the cloze makes the answer then disappear “opposite to what is happening for cloze 2”.

So following up on this trying to know more about the error, it seems the code stops displaying the card till the c number of the nested cloze.

I decided I should downgrade back to the non-persistence incremental cloze code (this post Suggestion: Persistent Incremental Cloze Reveal)

Old Code

Frontside:

image

Reveal Front side. Notice where it stopped the answer at.
image

Backside:
image

Edit view
image


With the new code above

Frontside (Unwanted behaviour)
image

Reveal Frontside (Same)
image

Backside (Same)
image

I want to stop it from displaying the answer on the frontside.

1 Like

Does it perhaps only work on every other card when you start a review session? On desktop the listener gets added for each card you review so, once again, add a flag to prevent that. Also, it might be that the if (elements && elements.length) condition is never true, so move the console.log outside that block. Then see if pressing g logs to the console so you know at least the keydown listener is triggering.

var resetKeyListenerAdded;
if (!resetKeyListenerAdded) {
  document.addEventListener(
    "keydown",
    function (e) {
      // Do nothing if the user is typing in an input or textarea.
      if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") {
        return;
      }
      if (e.key && e.key.toLowerCase() === "g") {
        console.log("Reset cloze state via keyboard shortcut (g).");
        if (elements && elements.length) {
          revealed = new Array(elements.length).fill(false);
          applyRevealedState();
          saveRevealedState();
          e.preventDefault();
          e.stopPropagation();
        }
      }
    },
    true
  );
  resetKeyListenerAdded= true;
}

Problem 2: Nested Cloze - Answer Displayed on Frontside (Regex problem?)

Maybe not a regex problem? regex101: build, test, and debug regex
It does seem to match almost the whole expression for c1. This part of is pretty complicated so I’m not making heads or tails about what’s happening there.

1 Like

Does it perhaps only work on every other card when you start a review session?

No it does not work like at all inside the review session. Where it does work, is inside the Card Template Editor.

I tried this change you made and it still does not seem to work.


Regarding the second problem, I tried once more again with Chatgpt o3 and DeepSeek R1 and they both hint at a problem with the getCloze function. That is where the problem lies.

I will try my luck with this now.

1 Like

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.