ニコニコ風にテキストを表示する React Hooks

ニコニコ動画・ニコニコ生放送のように、テキストをオーバーレイして流す React Hooks ライブラリ react-niconico を作ったので紹介します。

デモ: https://react-niconico.vercel.app/

使い方

useNiconico という Hooks を提供しています。useNiconico[MutableRefObject, (text: string) => void] なタプルを返します。ref を任意の要素に設定し、emitText に任意のテキストを渡すことでその要素上にテキストを流すことができます。

この例では video 要素に ref を設定していますが、任意の要素に使うことができます。

import { useEffect } from "react";
import { useNiconico } from "react-niconico";

export const App = () => {
  const [ref, emitText] = useNiconico();

  useEffect(() => {
    emitText("short text");
    emitText("looooooooooooong text");
  }, [emitText]);

  return <video ref={ref} src="/sample.mp4" />;
};

TypeScript では型引数に HTMLElement を満たす型を与える必要があります。

const [ref, emitText] = useNiconico<HTMLVideoElement>();

また、オプションとして以下を設定できます。

const [ref, emitText] = useNiconico({
  displayMillis: 5_000, // テキストの表示時間
  fontSize: 36, // テキストのサイズ
  lineWidth: 4, // テキストの縁の太さ
});

実装の話

canvas 要素を生成し、ref が設定された要素に重ねています。

// 実装イメージ

const canvas = document.createElement("canvas");

canvas.width = targetWidth;
canvas.height = targetHeight;

canvas.style.position = "absolute";
canvas.style.top = `${targetOffsetTop}px`;
canvas.style.left = `${targetOffsetLeft}px`;

ref.current.parentNode.insertBefore(canvas, ref.current);

また、対象の要素の大きさが変更された場合に検知するために ResizeObserver API を使っています。

テキストの表示時間・速度について

テキストの速度は、表示時間によって決まります。同じタイミングに流されたテキストは同じ時間かけて表示されます。なので、テキストが長くなれば速度も上がります。

0 秒後の表示

N 秒後の表示

テキストの Y 軸方向の位置について

ニコニコでは、表示領域内でテキストが重なる可能性がある場合は Y 軸方向に位置をずらします。

react-niconico でも同様のことをしています。現在表示しているすべてのテキストについて、そのテキストと同じ高さでテキストを流した場合に、表示領域内で重なるタイミングがあるかどうかを判定します。重なるタイミングがある場合、そのテキストの高さを予約済みとし、余っている高さの中で最も小さい高さにテキストを流します(詳細な実装)。

数秒後に追いつく例

重ならない高さで流す

重ならない高さが無い場合にニコニコがどういう規則でテキストを流しているか分からなかったのでそこは未実装です…

あとがき

テキストのスタイル(フォント、色、太字・細字など)を詳細に設定したい場合もあるかもしれないので CanvasRenderingContext2D をカスタムできるオプションがあればいいかもというのを後から思いました。

const customContext = useCallback((ctx) => {
  ctx.font = "bold 24px sans-serif";
  ctx.lineWidth = 3;
  ctx.strokeStyle = "#8c8c8c";
  ctx.fillStyle = "#fff";
}, []);

const [ref, emitText] = useNiconico({ customContext });