タイピングしてる感じをJSで表現しました。
ひらがなからローマ字への変換に
https://github.com/WaniKani/WanaKana
を使っています。
kuromoji.jsあたりを使えば分かち書きや読み推定できるでしょうが、
辞書のダウンロードが重くなりがちなので、今回は定型文のみ対応しています。
元々はSvelte + TypeScriptで実装してましたが、
需要がなさそうなのでvanilla JSで書き直しました。
index.html
<html lang="ja">
<head>
<meta charset="UTF-8" />
<style>
.before-conversion {
text-decoration: underline dotted;
}
.selecting-conversion {
text-decoration: underline solid;
}
</style>
</head>
<body>
<div class="typing-animation"></div>
<script src="https://unpkg.com/wanakana"></script>
<script src="./main.js"></script>
</body>
</html>
main.js
document.addEventListener("DOMContentLoaded", () => {
/**
* 一文字あたりの入力にかかる時間(ミリ秒)です。
*/
const charDuration = 200;
/**
* 指定された時間待ちます。
* @param {number} timeout ミリ秒
* @returns {Promise<void>} Promise
*/
function wait(timeout) {
return new Promise((resolve) => setTimeout(resolve, timeout));
}
/**
* ローマ字からひらがな、ひらがなから漢字となる変換を表します。
* @param {string} completed 変換後の文字列
* @param {number} duration 変換アニメーションの待機時間
* @param {ConversionEntry[]} children 子要素となる変換の配列
* @param {string} styleClass 子要素の変換アニメーション中に適用されるCSSクラス名
* @param {string} convertingClass この要素の変換アニメーション中に適用されるCSSクラス名
*/
function ConversionEntry(
completed,
duration,
children,
styleClass,
convertingClass
) {
/**
* 変換後の文字列
*/
this.completed = completed;
/**
* 変換後の文字数
*/
this.count = completed.length;
/**
* 変換アニメーションを直列に実行します。
* @param {TypingRenderer} renderer 描画ロジック
* @returns {Promise<void>} Promise
*/
this.animate = function (renderer) {
if (styleClass) {
renderer.intoSpan(styleClass);
}
return children
.reduce(
(prev, child) => prev.then(() => child.animate(renderer)),
Promise.resolve()
)
.then(() => {
if (convertingClass) renderer.replaceClass(convertingClass);
})
.then(() => wait(duration))
.then(() => {
if (styleClass) {
renderer.outOfSpan();
}
renderer.remove(
children.reduce((prev, child) => prev + child.count, 0)
);
renderer.append(completed);
});
};
}
/**
* 削除アニメーションを表します。
* @param {number} removeCount
*/
function RemoveEntry(removeCount) {
/**
* 削除アニメーションの待機時間は0です。
*/
this.duration = 0;
/**
* 変換後の文字数は削除文字数の負数です。
*/
this.count = -removeCount;
/**
* 一文字削除します。
* @param {TypingRenderer} renderer 描画ロジック
* @param {number} remains 残り削除文字数
* @returns {Promise<void>} Promise
*/
function removeOne(renderer, remains) {
if (remains) {
return wait(charDuration).then(() => {
renderer.remove(1);
return removeOne(renderer, --remains);
});
}
}
/**
* 変換アニメーションを直列に実行します。
* @param {TypingRenderer} renderer 描画ロジック
* @returns {Promise<void>} Promise
*/
this.animate = function (renderer) {
return removeOne(renderer, removeCount);
};
}
/**
* 入力文字を表します。
* @param {string} kanji 変換後の文字列
* @param {string} kana ひらがな
*/
function TypingInput(kanji, kana) {
/**
* 入力にかかる時間としてランダム性のあるミリ秒を返します。
* @returns {number} ミリ秒
*/
function randomDuration() {
return Math.random() * charDuration * 2 + charDuration / 4;
}
const komojis = "ゃゅょぁぃぅぇぉ".split("");
/**
* ローマ字入力において一度に入力する単位の配列を返します。
* @param {string} kana ひらがな
* @returns {string[]} ひらがなの配列
*/
function spreadKana(kana) {
return kana.split("").reduce((prev, char) => {
if (~komojis.indexOf(char) || prev[prev.length - 1] === "っ") {
prev[prev.length - 1] += char;
} else {
prev.push(char);
}
return prev;
}, []);
}
const convEntries = spreadKana(kana).map((k) => {
const romajis = wanakana.toRomaji(k).split("");
romajis.pop();
const romajiEntries = romajis.map(
(r) => new ConversionEntry(r, randomDuration(), [])
);
return new ConversionEntry(k, charDuration, romajiEntries);
});
/**
* この入力文字に相当する変換オブジェクトです。
*/
this.conversion = new ConversionEntry(
kanji,
charDuration * 2,
convEntries,
"before-conversion",
"selecting-conversion"
);
}
/**
* 入力としての文字削除を表します。
* @param {number} removeCount 削除文字数
*/
function TypingBackspace(removeCount) {
this.conversion = new RemoveEntry(removeCount);
}
/**
* 描画ロジックを表します。
* @param {HTMLElement} element
*/
function TypingRenderer(element) {
const hierarchies = [element];
/**
* 現在カーソルのある要素を返します。
* @returns {HTMLElement} HTML要素
*/
this.current = function () {
return hierarchies[hierarchies.length - 1];
};
/**
* CSSクラスを指定したspan要素を作成し、その内部にカーソルを移動します。
* @param {string} styleClass CSSクラス名
*/
this.intoSpan = function (styleClass) {
const span = document.createElement("span");
span.classList.add(styleClass);
this.current().appendChild(span);
hierarchies.push(span);
};
/**
* 現在のspan要素の外側へカーソルを移動します。
*/
this.outOfSpan = function () {
hierarchies.pop();
};
/**
* 現在の要素のCSSクラスを置き換えます。
* @param {string} styleClass CSSクラス
*/
this.replaceClass = function (styleClass) {
this.current().class = null;
this.current().classList.add(styleClass);
};
/**
* 文字列を追記します。
* @param {string} str 文字列
*/
this.append = function (str) {
this.current().textContent = this.current().textContent + str;
};
/**
* 文字列を指定の長さ削除します。
* @param {number} removeCount 削除する文字列の長さ
*/
this.remove = function (removeCount) {
const remains = removeCount - this.current().textContent.length;
this.current().textContent = this.current().textContent.substring(
0,
this.current().textContent.length - removeCount
);
if (remains > 0) {
const prev = this.current().previousElementSibling;
hierarchies.pop().remove();
if (prev) this.hierarchies.push(prev);
this.remove(remains);
}
};
}
/**
* アニメーションの描画を開始します。
* @param {string} completed アニメーション完了後の文字列
* @param {HTMLElement} element 描画先のHTML要素
* @param {(TypingInput | TypingBackspace)[]} typings 入力文字オブジェクトの配列
*/
function render(completed, element, typings) {
new ConversionEntry(
completed,
0,
typings.map((e) => e.conversion)
).animate(new TypingRenderer(element));
}
const typings = [
new TypingInput("素早い", "すばやい"),
new TypingInput("茶色の", "ちゃいろの"),
new TypingInput("キツネは", "きつねは"),
new TypingInput("少し", "すこし"),
new TypingBackspace(2),
new TypingInput("のろまな", "のろまな"),
new TypingInput("犬を", "いぬを"),
new TypingInput("飛び越える", "とびこえる"),
];
const targetElement = document.querySelector(".typing-animation");
render("素早い茶色のキツネはのろまな犬を飛び越える", targetElement, typings);
});