LoginSignup
1
1

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-04-30

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

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

これを使うと、このような形で早押しクイズの練習をすることができます:

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

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

概要

この記事でやること

  • Ankiで早押しクイズの表示をする際に、<b>〜</b>でくくった部分を太字で表示する

この記事でやらないこと

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

パラレル強調表示の実装

細かな説明はあとにして、先にコードの全体を出してしまいます。内容に興味のない人は、これをそのまま表面のテンプレートの部分にコピペすれば使えるはずです:

表面のテンプレート
<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>をパースする関数を作成
  2. 文字列表示にisBoldという変数を導入し、太字を表示している間は常に表示する文字列の末尾に</b>が入るようにする
  3. type_fieldへの表示をtextContentではなくinnerHTMLで行うようにする

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

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

前回は、

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

という形で、問題フィールドのテキストをそのまま1文字ずつ分けた配列を作って、これを1つずつHTML上に追加して表示していく形をとっていました。なので、例えば問題フィールドが

日本の首都はどこでしょう?

なら、

string = ['', '', '', '', '', '', '', '', '', '', '', '', '']

という形になっていました。

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

そこで、最初に<b>もしくは</b>での分割を行った上で、分割されたそれぞれをさらに1文字ごとに分割し、間に対応する<b>もしくは</b>を判定して挿入する形でparse_b(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;
}

このparse_b(string)関数によって、

日本で<b>一番</b>高い山は富士山ですが、<b>2番目に</b>高い山は何でしょう?

という文字列は、

['', '', '', '<b>', '', '', '</b>', '', '', '', '', '', '', '', '', '', '', '', '<b>', '2', '', '', '', '</b>', '', '', '', '', '', '', '', '', '', '']

のようにパースされます。

2. 太字の表示

上のparse_b(string)関数のパース結果をそのまま1つずつ追加して表示文字列とすると、太字部分の途中では<b>に対応する</b>がないことになってしまいます。そこで、現在太字の表示中かどうかを示す変数isBoldを導入し、これがtrueである間は、表示文字列の末尾に</b>が挿入されるように実装しました。

          ...
          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を代わりに使います。

          ...
          type_field.innerHTML = q_fraction;
          ...

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

まとめ

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

前回に引き続き、こちらもサンプル動画をtwitterに上げたので参考にしてください:

追記 2022/05/15

上ではフィールドに直接<b>〜</b>を書き込む形を取りましたが、{{text:問題}}の代わりに{{問題}}を使うことで、AnkiのブラウザでBボタンないし⌘+Bでの装飾をする形でも使うことができます。

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つは他のタグを実装してしまうというやり方もあります。

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()は、コピペしてタグの種類を増やしただけです。

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が都度付加される形になります。

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1