Ankiで早押しクイズ

暗記カードアプリ「Anki」で、早押しクイズを練習するためのカードを作ってみたので、備忘録として書いてみます。

なお、以下の記述は全てmacOSのAnki.appとiOS版のAnkimobile Flashcardsでのみ動作確認しています。Ankidroidなど他の環境での動作は保証できないのでご注意ください。

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

- Ankiで、ボタン／キーを押したらフィールドの文字列が前から順に表示されるようにする
- もう一度ボタン／キーを押したらそこで文字列の表示更新が止まるようにする

### この記事でやらないこと

- ここでは「みんなで早押しクイズ」などに見られるような問題文を文字で表示するタイプの出題のみを扱い、競技クイズの大会を模したような音声による早押し練習は扱わない
- 点数計算や、CPUとの対戦形式にする機能は実装しない

# Ankiのカードについて

### ノートとカードの基礎

まず、Ankiのノートとカードについての軽い説明です。ここではざっとした説明しかしないので、詳しい仕様については公式のdoc（日本語：https://wikiwiki.jp/rage2050/ 、英語：https://docs.ankiweb.net/ ）を参照してください。

Ankiでは、暗記カードのデータを「ノート」と呼ばれる形式で保持していて、それを実際の暗記カードにしたものを「カード」と呼んでいます。

「追加」画面を開くとこのような入力画面になっていますが、ここで編集しているのが「ノート」です。

![追加画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/c827ffbf-c242-66e0-8706-bc25bd272fe9.png)

各ノートはkey-value型のデータ形式で、このkeyのことを「フィールド」と呼んでいます。上の例だと「Front」と「Back」の2つのフィールドからなっています。

各フィールドに入力してノートを作り、デッキを選択して学習をスタートすると例えば次のような画面で学習を行うことになります：
<img width="49%" alt="カード表面" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/043adc94-f0bc-fe6c-aeaa-ddcc7980b920.png"> <img width="49%" alt="カード裏面" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/9fc6155f-51a4-e873-1e34-5582915d3ce7.png">

ここでは表面には「Front」のみ、裏面には「Front」と区切り線と「Back」の情報が表示されていますが、こうした各面のスタイルを決めているのが「カード」です。

### カードの編集

このカードですが、html+cssを使ってそこそこ自由にカスタマイズすることができます。メニューバーの「ノートタイプを管理」から、使いたいノートタイプを選択して、右の「カード」ボタンをクリックします
![ノートタイプを管理](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/55fcf978-1f7d-da24-5870-7aac4a108198.png)

そうすると、このような画面が開きます。この左側のテンプレートが、htmlをベースにしたテンプレートとなっています。
![カードを編集](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/994c0380-a8c4-fde4-22cc-9b00ee39efd9.png)

さらに、「書式」のラジオボタンでは、cssでスタイルを指定することができます。
![書式](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/ee1af2f6-990a-d3bb-3c90-0c99f2ba8163.png)

そして、このテンプレートですが、`<script>`タグを使うことによって、javascriptを動かすことができてしまいます。
![JavaScriptの例](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/731d3663-03f9-f5cb-9795-9f1ceb0c8770.png)

また、裏でjquery.jsの読み込みもしているようで、jQueryも使えるようです。

これを使って、早押しクイズの練習をするためのカードを実装してみよう、というのが今回の趣旨になります。

# 早押しカードの実装

### ノートの設定

上で例に出したFront/Backの基本ノートでもいいのですが、クイズの問題を管理する上では問題／解答以外にフィールドがあった方が使いやすいので、私は「問題」「解答」「解説」の3つのフィールドからなるノートを定義して使っています：
![ノートの設定](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/21344132-13b6-744e-f932-ef6e59134832.png)

### 裏面のテンプレート

裏面では早押し関係なく単に問題+解答+解説を表示するだけなので、先に裏面のテンプレートを編集します。

```html:裏面のテンプレート
<div class="container">
  <div class="main">
    <div class="question">
      <p>{{text:問題}}</p>
      <hr>
      <p>{{text:解答}}</p>
    </div>
    <div class="description">
      <p>{{text:解説}}</p>
    </div>
  </div>
</div>
```

```css:書式
.card {
  font-family: Hiragino Sans;
  font-size: 20px;
  text-align: left;
  color: black;
  background-color: white;
  margin: 0;
  padding: 0;
}

.container {
  display: flex;
  flex-direction: column;
}

.main {
  flex: 1;
}

.question {
}

.description{
  font-size: 16px;
}
```

テンプレート内で`{{}}`で囲った部分には、対応するフィールドの値が入ります。何も指定せず`{{問題}}`などとすればhtml形式で挿入されますが、`{{text:問題}}`のように指定すると単なる文字列として挿入されます。ここでは細かい体裁を自分で制御したいので、`text:`を付ける方を使いました。

<b>※2022/04/30追記</b>
<font color="gray">このテンプレートでは`{{text:問題}}`をそのまま書いていますが、問題文に`'`や`"`の引用符が使われている場合は、バックスラッシュでエスケープしてJavaScriptでのSyntaxErrorを回避する必要があることに気づきました。この場合、ここで書いた`{{text:問題}}`をそのまま書くやり方だと裏面にはバックスラッシュもそのまま表示されてしまいます。このケースの対処法については記事の最後に追記しているのでそちらを参照してください。</font>

また、全体を`.container`と`.main`のdivで囲っていますが、これは後述する表面のレイアウトとの兼ね合いです。

ここまでの実装で、裏面のテンプレートとプレビューはこのような形になります。
![裏面のテンプレート](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/faef8e5e-6189-e95a-6259-e8867199844e.png)

### 表面のテンプレート

次に表面のテンプレートです。表面には、文字情報としては`{{問題}}`フィールドだけを表示します。また、早押し機能に使うためのボタンを配置したいのですが、特にスマホでの利用の際には指の届きやすい下の方にボタンを配置した方が便利です。そこで、flexboxを用いて位置を調整します。

```html:表面のテンプレート
<div class="container">
  <div class="main">
    <div class="question">
      <p id="typewriter">{{text:問題}}</p>
    </div>
  </div>
  <div class="footer">
    <button id="hayaoshi_button" onclick="hayaoshi()" onkeypress="hayaoshi()">start</button>
  </div>
</div>
```

```css:書式
.card {
  font-family: Hiragino Sans;
  font-size: 20px;
  text-align: left;
  color: black;
  background-color: white;
}

.container {
  display: flex;
  flex-direction: column;
  height: 90vh;
  height: calc(var(--vh, 1vh) * 90);
}

.main {
  flex: 1;
  margin: 0;
  padding: 0;
}

.question {
}

.description{
  font-size: 16px;
}

.footer {
  display: flex;
  justify-content: center;
  align-items: flex-end;
}

#hayaoshi_button {
  padding: 20px 36px;
  font-size: 18px;
  background: none;
  color: black;
  border: 1px solid gray;
  border-radius: 6px;
  outline: none;
  -webkit-box-sizing: content-box;
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  box-sizing: border-box;
}
```

![表面のテンプレート](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/f1da41f4-5f48-885d-044c-c628f52c3a09.png)

なお、ここで`.container`の高さを`90vh`（viewport heightの90%）に指定しているのは、スマホでのボタンの表示位置の調整のためです。ただ、これだけではviewport heightの長さの取得が合わないので、次節で説明するJavaScriptの中で
```javascript
let vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
```
のようにvhの値を取得する処理を入れています（参考：https://coliss.com/articles/build-websites/operation/css/viewport-units-on-mobile.html ）。
このあたり、何かもう少しスマートなやり方がありそうなので、詳しい方がいればご教示いただけるとありがたいです。

### JavaScriptで早押し機能の実装

さて、それでは次に、JavaScriptで早押し機能を実装するところを見ていきます。

まず最初に、AnkiでJavaScriptを使う時の注意点です。テンプレートの中にJavaScriptのコードを書く際に、
```html
<script>
var question = "日本一高い山は何でしょう？"
</script>
```
のように直接変数を宣言してしまうと、グローバルスコープになってしまいます。なので、これでそのまま早押しの機能を実装してしまうと、次の問題に移ったときに前の問題での変数の値が残ってしまって、うまく動きません。こうした問題を回避するためには、次のように無名関数の中に処理を入れて即時実行するのが望ましいようです（参考：https://laboradian.com/using-javascript-in-anki/ ）。
```html
<script>
(() => {
  // ここにコードを書く
})()
</script>
```

これに注意して、実際に早押し機能を実装したのがこちらになります：
```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 hayaoshi() {
  if (!isStarted) {
    isStarted = true;
    hayaoshi_button.textContent = 'stop';

    question = '{{text:問題}}'.toString();
    string = question.split('');
    type_field.textContent = '';

    for (const [index, char] of string.entries()) {
      const timerID = setTimeout(() => {
        if (!isStopped) {
          type_field.textContent += char;
        }
        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(() => {
  $(document).off('keydown').keydown(function (event) {
    if (event.which === 72) {
      hayaoshi();
    }
  })
  
  $(document).on("click", "#hayaoshi_button", function(){
    hayaoshi();
  });
})

})()
```

表面のテンプレートで直書きしていた`{{text:問題}}`を消して、上のJavaScriptのコードを`<script>〜</script>`で囲ったものを適当に配置すれば動きます。
![表面のテンプレート(JavaScript)](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/2c6796cf-8ff9-7869-9aa2-d34fb2019414.png)



上の`hayaoshi()`関数は、おおまかな仕組みとしては、

- 問題文を1文字ごとに区切った配列`string`を用意する
- `setTimeout()`で指定した時間（ここでは100ms）ごとに、`string`から1文字追加したものを画面に表示する
- ボタンが押されたら、`string`配列を空にし、ループを終わらせて画面に問題文がそれ以上表示されないようにする

といったものになっています。この`hayaoshi()`関数を、ボタンのクリックイベントや指定したkeydownイベントによって動かしてやることで、早押し機能を実現しています。

文字の表示スピードは、`const speed=100;`の箇所の数字を変えることで調整が可能です。

keydownで関数を走らせる条件は、ここでは'H'キーに対応する`72`を入れています。これは対応するキーコードを入れることで任意のキーに対応づけることができますが、SpaceやEnterなどはAnkiの標準のショートカットと干渉することになるので使うことができません。私は知り合いに作ってもらった早押しボタンでよく練習をしますが、ボタンの対応するキーがEnterなので、Karabiner-Elements（https://karabiner-elements.pqrs.org/ ）を用いてキーバインドを変更することで対処しています。Ankiのみで完結させられる方法としては、ショートカットを変更するアドオン（https://ankiweb.net/shared/info/24411424 ）を用いて衝突を回避するやり方も使えるようです。

# まとめ

Ankiを使って、ボタン／キーによる早押し練習のカードを実装してみました。Ankiは暗記カードアプリの中でも特にこういうカスタマイズの自由度が高いので、色々と有効活用していきたいですね。

今回実装した早押し練習カードを実際に動かしている様子はtwitterに動画で上げているので、こちらも参考にしてみてください。PC版はボタンのレイアウトを調整する前のバージョンなので見た目が少し違いますが動作はだいたい同じです。

<b>PC版</b>
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">前々からやろうと思って先延ばしにしてたけど、Ankiでカードのテンプレートに&lt;script&gt;で関数を書いて早押しの練習ができるようにしたやつを作った <a href="https://t.co/eBqiWIIaQN">pic.twitter.com/eBqiWIIaQN</a></p>&mdash; T.Tokunaga（濃青だった人）@クイズ (@nosei_quiz) <a href="https://twitter.com/nosei_quiz/status/1509384277699424260?ref_src=twsrc%5Etfw">March 31, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> 

<br>

<b>iOS版</b>
<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> 

# 追記 2022/04/30

Ankiのカードでは、テンプレートに`{{text:問題}}`と書いた場合、「問題」フィールドのテキストでそのままこの`{{text:問題}}`を置換したものがhtmlとして解釈されます。なので、ここで紹介したコードで、問題文中に引用符`'`が入っていると、
```javascript
question = '問題文中に'が含まれる場合'.toString();
```
のようなコードとして解釈されることとなり、SyntaxErrorでうまく動作しないようです。引用符を二重引用符にして書いた場合には、同様に問題文中に二重引用符があるとSyntaxErrorとなります。

JavaScriptでは、引用符の前に`\'`や`\"`のようにバックスラッシュを付けるとエスケープすることができるので、この早押しカードを使う場合には、ノートフィールド中の引用符にバックスラッシュを付けるといいでしょう。

ただ、そうすると裏面のテンプレートでは`{{text:問題}}`をそのまま記述しているので、裏面にはエスケープするためのバックスラッシュがそのまま表示されてしまいます。そこで、裏面でも一度JavaScriptを経由して表示するように改造します。

```html:裏面のテンプレート
<div class="container">
  <div class="main">
    <div class="question">
      <p id="question-text"></p>
      <hr>
      <p>{{text:解答}}</p>
    </div>
    <div class="description">
      <p>{{text:解説}}</p>
    </div>
  </div>
</div>

<script>

(() => {

const question = '{{text:問題}}';
const question_field = document.getElementById('question-text');

$(document).ready(() => {
  question_field.textContent = question;
});

})();
```

こうすることで、問題文が一度JavaScriptの文字列として評価された後に表示されることになるので、表面と同じ表示に揃えることができます。