少し前に、暗記カードアプリ「Anki」を使って早押しクイズをするカードの実装について書きました。
Ankiで早押しクイズ:https://qiita.com/WaTTson496/items/66f6052be1a16905805a
これを使うと、このような形で早押しクイズの練習をすることができます:
Ankiの早押し練習用カード、スマホ対応した pic.twitter.com/6JP14M6HsN
— T.Tokunaga(濃青だった人)@クイズ (@nosei_quiz) April 23, 2022
この記事で実装した内容では、単に問題文を前から順に表示するだけで、音声でのクイズと違って抑揚が分かりません。競技クイズでは特にパラレル問題について、抑揚・強調で先読みをするという技術が確立しているので、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点です:
- 問題フィールドの
<b>〜</b>
をパースする関数を作成 - 文字列表示に
isBold
という変数を導入し、太字を表示している間は常に表示する文字列の末尾に</b>
が入るようにする - 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に上げたので参考にしてください:
Ankiで早押しクイズのやつ、パラレル対応した pic.twitter.com/ZZZspkDjF4
— T.Tokunaga(濃青だった人)@クイズ (@nosei_quiz) April 30, 2022
追記 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;
}
...