3
4

More than 1 year has passed since last update.

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

Posted at

ezgif.com-gif-maker.gif

タイピングしてる感じを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);
});

3
4
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
3
4