Ankiで早押しクイズ その2 —パラレルの強調表示

少し前に、暗記カードアプリ「Anki」を使って早押しクイズをするカードの実装について書きました。

Ankiで早押しクイズ：https://qiita.com/WaTTson496/items/66f6052be1a16905805a

これを使うと、このような形で早押しクイズの練習をすることができます：
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">Ankiの早押し練習用カード、スマホ対応した <a href="https://t.co/6JP14M6HsN">pic.twitter.com/6JP14M6HsN</a></p>&mdash; T.Tokunaga（濃青だった人）@クイズ (@nosei_quiz) <a href="https://twitter.com/nosei_quiz/status/1517836827419619329?ref_src=twsrc%5Etfw">April 23, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> 

この記事で実装した内容では、単に問題文を前から順に表示するだけで、音声でのクイズと違って抑揚が分かりません。競技クイズでは特にパラレル問題について、抑揚・強調で先読みをするという技術が確立しているので、Ankiでの早押し練習でもこれに対応できると良いと思いました。

そこで、パラレルの対になっている部分を読み上げで強調するのと対比させて、文字でも<b>太字</b>で表示する形にして対処することを考えました。

# 概要

### この記事でやること

- Ankiで早押しクイズの表示をする際に、`<b>〜</b>`でくくった部分を太字で表示する
 
### この記事でやらないこと

- Ankiで早押しをするカード本体の説明はここではしません。前回の記事（https://qiita.com/WaTTson496/items/66f6052be1a16905805a ）の方をみてください
- `<b>〜</b>`をテキストから自動で判別する、といったことはしていないので、全部自分でタグを付けていく必要があります

# パラレル強調表示の実装

細かな説明はあとにして、先にコードの全体を出してしまいます。内容に興味のない人は、これをそのまま表面のテンプレートの部分にコピペすれば使えるはずです：
```html:表面のテンプレート
<div class="container">
  <div class="main">
    <div class="question">
      <p id="typewriter"></p>
    </div>
  </div>
  <div class="footer">
    <button id="hayaoshi_button">start</button>
  </div>
</div>

<script>

(() => {

let vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);

const type_field = document.getElementById('typewriter');
const hayaoshi_button = document.getElementById('hayaoshi_button');
const speed = 100;
let isStarted = false;
let isStopped = false;
let question = '';
let string = [];

function parse_b(string) {
  let split_b = string.split(/<b>|<\/b>/);
  let res = [];
  let string_residue = string;
  for (fraction of split_b) {
    res = res.concat(fraction.split(''));
    string_residue = string_residue.replace(fraction, '');
    if (string_residue.startsWith('<b>')) {
      res.push('<b>');
      string_residue = string_residue.replace('<b>', '');
    } else if (string_residue.startsWith('</b>')) {
      res.push('</b>');
      string_residue = string_residue.replace('</b>', '');
    }
  }
  return res;
}

function hayaoshi() {
  if (!isStarted) {
    isStarted = true;
    hayaoshi_button.textContent = 'stop';

    question = '{{text:問題}}';
    string = parse_b(question);
    type_field.innerHTML = '';
    isBold = false;
    q_fraction = '';

    for (const [index, char] of string.entries()) {
      const timerID = setTimeout(() => {
        if (!isStopped) {
          if (isBold) {
            if (q_fraction.endsWith('</b>')) {
              q_fraction = q_fraction.slice(0, -4);
            }
            q_fraction += char + '</b>';
            if (char === '</b>') {
              isBold = false;
              clearTimeout(timerID);
            }
          } else {
            q_fraction += char;
            if (char === '<b>') {
              isBold = true;
              clearTimeout(timerID);
            }
          }
          type_field.innerHTML = q_fraction;
        }
        if (index >= string.length - 1) {
          isStopped = true;
          hayaoshi_button.hidden = true;
        }
      }, speed * index);
      if (isStopped) {
        hayaoshi_button.hidden = true;
        clearTimeout(timerID);
        question = '';
        string = [];
        break;
      }
    }
  } else {
    isStopped = true;
    hayaoshi_button.hidden = true;
    question = '';
    string = [];
  }
}

$(document).ready(function () {
  $(document)
    .off('keydown')
    .keydown(function (event) {
      if (event.which === 72) {
        hayaoshi();
      }
    });

  $(document).on('click', '#hayaoshi_button', function () {
    hayaoshi();
  });
});

})()

</script>
```

前回の記事で実装した内容から変えている部分は3点です：
1. 問題フィールドの`<b>〜</b>`をパースする関数を作成
1. 文字列表示に`isBold`という変数を導入し、太字を表示している間は常に表示する文字列の末尾に`</b>`が入るようにする
1. type_fieldへの表示を`textContent`ではなく`innerHTML`で行うようにする

以下、順に細かな解説をしていきます。

### 1. 問題フィールドの`<b>〜</b>`のパース

前回は、
```javascript
question = '{{text:問題}}'.toString();
string = question.split('');
```
という形で、問題フィールドのテキストをそのまま1文字ずつ分けた配列を作って、これを1つずつHTML上に追加して表示していく形をとっていました。なので、例えば問題フィールドが

```text
日本の首都はどこでしょう？
```
なら、
```javascript
string = ['日', '本', 'の', '首', '都', 'は', 'ど', 'こ', 'で', 'し', 'ょ', 'う', '？']
```
という形になっていました。

今回のパラレル対応では、間に`<b>`と`<\b>`とそれぞれ複数文字からなるタグを入れているので、これらを抽出した上で残りは1文字ごとに分離する、という必要があります。

そこで、最初に`<b>`もしくは`</b>`での分割を行った上で、分割されたそれぞれをさらに1文字ごとに分割し、間に対応する`<b>`もしくは`</b>`を判定して挿入する形で`parse_b(string)`という関数を実装しました。

```javascript
function parse_b(string) {
  let split_b = string.split(/<b>|<\/b>/);
  let res = [];
  let string_residue = string;
  for (fraction of split_b) {
    res = res.concat(fraction.split(''));
    string_residue = string_residue.replace(fraction, '');
    if (string_residue.startsWith('<b>')) {
      res.push('<b>');
      string_residue = string_residue.replace('<b>', '');
    } else if (string_residue.startsWith('</b>')) {
      res.push('</b>');
      string_residue = string_residue.replace('</b>', '');
    }
  }
  return res;
}
```

この`parse_b(string)`関数によって、
```text
日本で<b>一番</b>高い山は富士山ですが、<b>2番目に</b>高い山は何でしょう？
```
という文字列は、
```javascript
['日', '本', 'で', '<b>', '一', '番', '</b>', '高', 'い', '山', 'は', '富', '士', '山', 'で', 'す', 'が', '、', '<b>', '2', '番', '目', 'に', '</b>', '高', 'い', '山', 'は', '何', 'で', 'し', 'ょ', 'う', '？']
```
のようにパースされます。

### 2. 太字の表示
上の`parse_b(string)`関数のパース結果をそのまま1つずつ追加して表示文字列とすると、太字部分の途中では`<b>`に対応する`</b>`がないことになってしまいます。そこで、現在太字の表示中かどうかを示す変数`isBold`を導入し、これが`true`である間は、表示文字列の末尾に`</b>`が挿入されるように実装しました。
```javascript
          ...
          if (isBold) {
            if (q_fraction.endsWith('</b>')) {
              q_fraction = q_fraction.slice(0, -4);
            }
            q_fraction += char + '</b>';
            if (char === '</b>') {
              isBold = false;
              clearTimeout(timerID);
            }
          } else {
            q_fraction += char;
            if (char === '<b>') {
              isBold = true;
              clearTimeout(timerID);
            }
          }
          ...
```

### 3. innerHTMLでの表示
最後に、この`q_fraction`を実際に`type_field`に表示しますが、表示するときに`textContent`を使うと`<b>〜</b>`タグがHTMLとして解釈されないので、`innerHTML`を代わりに使います。

```javascript
          ...
          type_field.innerHTML = q_fraction;
          ...
```

これで、問題文中に`<b>〜</b>`を挿入すれば、対応する部分が太字で表示される形で早押しの練習を行うことができます。

# まとめ

Ankiを使った早押しカードの実装で、パラレル問題を音声での出題に近づけるために、`<b>〜</b>`タグによる強調表示に対応するよう改修を行いました。今回は`<b>〜</b>`だけ対応しましたが、たぶん同様にして他のHTMLタグにも対応させるのはそれほど難しくないと思います。

前回に引き続き、こちらもサンプル動画をtwitterに上げたので参考にしてください：
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">Ankiで早押しクイズのやつ、パラレル対応した <a href="https://t.co/ZZZspkDjF4">pic.twitter.com/ZZZspkDjF4</a></p>&mdash; T.Tokunaga（濃青だった人）@クイズ (@nosei_quiz) <a href="https://twitter.com/nosei_quiz/status/1520245586762485761?ref_src=twsrc%5Etfw">April 30, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> 

# 追記 2022/05/15

上ではフィールドに直接`<b>〜</b>`を書き込む形を取りましたが、`{{text:問題}}`の代わりに`{{問題}}`を使うことで、Ankiのブラウザで<b>B</B>ボタンないし⌘+Bでの装飾をする形でも使うことができます。
```javascript
function hayaoshi() {
  if (!isStarted) {
    isStarted = true;
    hayaoshi_button.textContent = 'stop';

-    question = '{{text:問題}}';
+    question = '{{問題}}';
    string = parse_b(question);
    type_field.innerHTML = '';
    isBold = false;
    q_fraction = '';
```

ただしこの場合、`question`には`<i>`とか`<u>`のような他のタグが入りうることになります。今回は`<b>`のパースしか実装しておらず、パース結果
```
['装', '飾', 'に', '<', 'i', '>', '斜', '体', '<', '/', 'i', '>', 'や', '<', 'u', '>', '下', '線', '<', '/', 'u', '>', 'を', '使', 'う', 'と', '挙', '動', 'が', 'お', 'か', 'し', 'く', 'な', 'り', 'ま', 'す']
```
の中で`'<', 'i', '>'`などと分割されたhtmlタグが`<`と`<i`の間だけ表示されてしまいます。

一つの解決策は`<b>`以外のタグを使わないことですが、もう1つは他のタグを実装してしまうというやり方もあります。

```javascript
let vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);

const type_field = document.getElementById('typewriter');
const hayaoshi_button = document.getElementById('hayaoshi_button');
const speed = 100;
let isStarted = false;
let isStopped = false;
let question = '';
let string = [];

function parse_deco(string) {
  let split_deco = string.split(/<b>|<\/b>|<i>|<\/i>|<u>|<\/u>/);
  let res = [];
  let string_residue = string;
  for (fraction of split_deco) {
    res = res.concat(fraction.split(''));
    string_residue = string_residue.replace(fraction, '');
    if (string_residue.startsWith('<b>')) {
      res.push('<b>');
      string_residue = string_residue.replace('<b>', '');
    } else if (string_residue.startsWith('</b>')) {
      res.push('</b>');
      string_residue = string_residue.replace('</b>', '');
    } else if (string_residue.startsWith('<i>')) {
      res.push('<i>');
      string_residue = string_residue.replace('<i>', '');
    } else if (string_residue.startsWith('</i>')) {
      res.push('</i>');
      string_residue = string_residue.replace('</i>', '');
    } else if (string_residue.startsWith('<u>')) {
      res.push('<u>');
      string_residue = string_residue.replace('<u>', '');
    } else if (string_residue.startsWith('</u>')) {
      res.push('</u>');
      string_residue = string_residue.replace('</u>', '');
    }
  }
  return res;
}

function hayaoshi() {
  if (!isStarted) {
    isStarted = true;
    hayaoshi_button.textContent = 'stop';

    question = '{{問題}}';
    string = parse_deco(question);
    type_field.innerHTML = '';
    q_fraction = '';
    deco = [];

    for (const [index, char] of string.entries()) {
      const timerID = setTimeout(() => {
        if (!isStopped) {
          if (deco.length) {
            deco_end = deco.map(x => x.replace('<', '</')).reverse().join();
            if (q_fraction.endsWith(deco_end)) {
              q_fraction = q_fraction.slice(0, -deco_end.length);
            }
            q_fraction += char + deco_end;
            if (char === '</b>' || char === '<i>' || char === '<u>') {
              deco.pop();
              clearTimeout(timerID);
            }
          } else {
            q_fraction += char;
            if (char === '<b>' || char === '<i>' || char === '<u>') {
              deco.push(char);
              clearTimeout(timerID);
            }
          }
          type_field.innerHTML = q_fraction;
        }
        if (index >= string.length - 1) {
          isStopped = true;
          hayaoshi_button.hidden = true;
        }
      }, speed * index);
      if (isStopped) {
        hayaoshi_button.hidden = true;
        clearTimeout(timerID);
        question = '';
        string = [];
        break;
      }
    }
  } else {
    isStopped = true;
    hayaoshi_button.hidden = true;
    question = '';
    string = [];
  }
}

$(document).ready(function () {
  $(document)
    .off('keydown')
    .keydown(function (event) {
      if (event.which === 72) {
        hayaoshi();
      }
    });

  $(document).on('click', '#hayaoshi_button', function () {
    hayaoshi();
  });
});

```

`parse_b()`の代わりに実装した`parse_deco()`は、コピペしてタグの種類を増やしただけです。
```javascript
function parse_deco(string) {
  let split_deco = string.split(/<b>|<\/b>|<i>|<\/i>|<u>|<\/u>/);
  let res = [];
  let string_residue = string;
  for (fraction of split_deco) {
    res = res.concat(fraction.split(''));
    string_residue = string_residue.replace(fraction, '');
    if (string_residue.startsWith('<b>')) {
      res.push('<b>');
      string_residue = string_residue.replace('<b>', '');
    } else if (string_residue.startsWith('</b>')) {
      res.push('</b>');
      string_residue = string_residue.replace('</b>', '');
    } else if (string_residue.startsWith('<i>')) {
      res.push('<i>');
      string_residue = string_residue.replace('<i>', '');
    } else if (string_residue.startsWith('</i>')) {
      res.push('</i>');
      string_residue = string_residue.replace('</i>', '');
    } else if (string_residue.startsWith('<u>')) {
      res.push('<u>');
      string_residue = string_residue.replace('<u>', '');
    } else if (string_residue.startsWith('</u>')) {
      res.push('</u>');
      string_residue = string_residue.replace('</u>', '');
    }
  }
  return res;
}
```

`hayaoshi()`の中で、`isBold`の代わりに`deco`という配列を作って管理することにします。これは、タグが入れ子になっている場合その中身を保持しておく必要があるためです。`q_fraction`の末尾には、終了タグを逆順にした`deco_end`が都度付加される形になります。
```javascript
function hayaoshi() {
  if (!isStarted) {
    isStarted = true;
    hayaoshi_button.textContent = 'stop';

    question = '{{問題}}';
    string = parse_deco(question);
    type_field.innerHTML = '';
    q_fraction = '';
    deco = [];

    for (const [index, char] of string.entries()) {
      const timerID = setTimeout(() => {
        if (!isStopped) {
          if (deco.length) {
            deco_end = deco.map(x => x.replace('<', '</')).reverse().join();
            if (q_fraction.endsWith(deco_end)) {
              q_fraction = q_fraction.slice(0, -deco_end.length);
            }
            q_fraction += char + deco_end;
            if (char === '</b>' || char === '<i>' || char === '<u>') {
              deco.pop();
              clearTimeout(timerID);
            }
          } else {
            q_fraction += char;
            if (char === '<b>' || char === '<i>' || char === '<u>') {
              deco.push(char);
              clearTimeout(timerID);
            }
          }
          type_field.innerHTML = q_fraction;
        }
...
```
![deco.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/1c02c17a-04e6-34d1-1d31-ac3e89a1a271.gif)