Reactで日本語のクロスワードを扱うことができる @naari3/react-crossword-ja を作成した/「最強無敵スーパーウルトラ神神神合作」のクロスワードのWeb実装をつくった

きっかけ

先日、最強無敵スーパーウルトラ神神神合作という音MAD合作が投稿された。

笑いや勢いが強いパートの多い音MAD合作で、個人的にとても好きなものだった。主催の微熱さんによってこの合作の制作進行についての記事が挙げられている。

note.com

こちらにも書いてあるが、この合作は告知としてクロスワードが使用された。先にTwitterに投稿されたものは一箇所ミスがあったようなので、今から解きたい人は上のnoteを参照してください。それで、記事内クロスワードの項目最後にこんなことが書かれていた。

15x21マスのクロスワードをWeb上で遊べるようなサイトや仕組みが見つからなかったのですが、Webプログラミングできる人なら簡単に作れそう。なありさーーーん?

そりゃもうやるしかないよね、ということで制作に着手した。完成品は以下からどうぞ。スマホ未対応です。

@jaredreisinger/react-crossword について

この時、一番最初にやったのはクロスワードのためのライブラリ探しだった。仕組みをイチから作るのはあまりにも非効率的なので、使い回せるものは使いまわしたい。それですぐに以下のライブラリを発見できた。

@jaredreisinger/react-crossword はReactの上に動くクロスワード用のライブラリ。以下のように答えのデータからクロスワードの盤面を自動生成してくれる。答えを入れてみると分かるが、文字を入力すると現在フォーカスをあてているカギの方向に応じて自動で次のマスに移動してくれたり、矢印キーでの移動やBackspaceキーでの文字削除などもできるのでなかなか体験が良い。実装としては、単一かつ見えないinput要素、svgで表現された盤面が存在する。盤面上のマスをクリックするとその位置にinput要素が移動し、文字を入力するとsvg側のtext要素に文字を埋めていくようなhooksが組まれている。

日本語の非対応ぶり

最初はこのライブラリで充分なんじゃないか?と思ったんだけど、しかし想像通りかつとても残念なことに、日本語入力に全く対応できていない状態だった。IMEのことを何も考えてられていない実装で、具体的には「りんご」という単語を入力する時、「RりりNりんGりんご」という入力に化けてしまう。これはIMEの「入力中かつ確定していない」ような状態の取り扱いを考えず、文字として認識できるタイミングで即時で扱うためにこうなってしまう。

@naari3/react-crossword-ja について

そのため、このライブラリをフォークして自分で色々用意することにした。

日本語入力を扱う

まず、ライブラリ名のsuffixとしてjaが付いていることが示すように、日本語の、しかもひらがなにのみ対応するようにした。上に挙げたIMEの件の他に、日本語圏のクロスワードは「っ」と「つ」を同一視するようなものがよく見当たるが、このようなルールを外から注入できるような仕組みを作るより別ライブラリに切り出したほうが嬉しそうだなと考えたため。スコープは狭いほうが良い。

その上で日本語特有っぽい文字入力の挙動について色々考えることにした。実装に至っては様々な罠を踏み抜いてしまい、本業がフロントエンドではない自分にはゼロベースで対応することになったため結構な苦労を要した。例えば、日本語入力中、未確定の状態だと文字キーとしてPROCESSが振ってくるだとか、compositionという状態の概念だとか、さらにブラウザ間の実装の差異による振る舞いの違いだったり、結構大変だった。最初はWordleの漢字バージョン「漢字ル」を作った - 詩と創作・思索のひろばmotemenさんがやっているように onsubmit で拾うような実装も考えたが、今回はひらがなのみが対象であり、英語版に見られるような入力が即時で反映されるような仕組みを生きたままにしたかったため、未変換状態の文字列をそのまま使うように対応した。

具体的には、input要素に入った文字列をstateとして保存した後、カーソル number 相当の添字を用意しておき、 文字列[カーソル] が指す文字がひらがなであったらその文字を入力確定として扱い、添字を +1 させる。これで入力中のアルファベットが拾われる問題に対処できた。inputの onComposionEnd という、日本語変換が完了した時に発火されるイベントにフックして上記の各stateをリセットさせるくらいのことしかやっていない。

inputを常に空にするとonCompositionEndが発火しない問題

最初はinput要素を隠すために以下のように実装していたのだが、これだと onCompositionEnd は発火しない。

<input value="" onCompositionEnd={() => console.log("fire!")} />

未確定/入力中の文字列は onChange などによって拾われるが、onCompositionEnd はinput要素内に未確定な文字列がある状態からでないと発火しない様子だった。

これがバグなのかはちょっとわからないが、対応としては入力中の文字列はきちんとvalueに格納するようにした上でstyleで文字列の透明度を100%にした。見えなければなんでもよい。

スマホ対応について

今回はパソコンのChromeから確認を行った。スマホ対応は時間が足りず行うことができなかったが、問題だけは把握している。スマホでの日本語入力のシナリオで「りんご」と入力することを考える時、「り」「ん」「こ」「Backspace」「ご」というキー入力が飛んでくる。このライブラリでのBackspaceの扱いが「現在のマスを削除しながら一つ前のマスに戻る」という挙動をするため、クロスワードのマスの終端にあたる場合に馬が合わなくなってしまう。スマホであれば以下から試せるが、「さが」のようになる。

対応としては今の入力方式がスマホの日本語入力であるか否かを判断し、そうであればBackspaceの挙動を変更したいのだけれど、どうすればそれが取れるかわからない。もし判断方法が見つからず、かつスマホも対応させたい場合はBackspaceの機能を落とすことでも解決できるとは思う。

おわり

といった具合でとりあえずパソコンから動く日本語対応のクロスワードライブラリを作成することができた。スマホ対応さえできればもうちょっとおもしろいサービスを展開できそうな気もするが、それはまた別の話ということで。