vanilla JSでタイピングしてる風なアニメーション

![ezgif.com-gif-maker.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126428/be953905-f6cf-77ae-db26-d6acd757882e.gif)

タイピングしてる感じをJSで表現しました。

ひらがなからローマ字への変換に
https://github.com/WaniKani/WanaKana
を使っています。

kuromoji.jsあたりを使えば分かち書きや読み推定できるでしょうが、
辞書のダウンロードが重くなりがちなので、今回は定型文のみ対応しています。

元々はSvelte + TypeScriptで実装してましたが、
需要がなさそうなのでvanilla JSで書き直しました。

```html: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>
```

```js: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);
});

```