Twitter風文字カウントを実装してみた

筆者は社内SE業務で、Twitter風の文字超過をハイライトする入力欄を作成した。
その成果をここに共有する。
どうせなら**Qiitaで投稿できるように、徹底的にTwitterを真似てしまえ**ということで、[Twitterの文字計測](https://afila0.com/twitter-character-count/ "Twitter文字数カウント５ルール | URLや改行は何文字？ - 作業ロケット")をそのままコードに落とし込んだ（社内SE成果物とは数え方が違うので悪しからず）。
ASP.NET MVCで作成したアプリ経由でTwitterに投稿したい場合には使えるはず（その手のサポートをしてくれる会社のサービスを利用する方が楽だろうが）。ライセンスはMITライセンスなので、自由に使用可能だ。
#実装方法
使用したのはASP.NET MVC 5（ASP.NET Coreでは`TagBuilder`の仕様が異なり、`ToString(TagRenderMode)`が読みだせないようなので、`GenerateElement`の書き直しが必要）。.Netのバージョンは4.6.1。C#により入力欄と超過部分のテンプレート生成をしている。実際にテンプレートを運用するのはJavaScriptの仕事。ということで責任の重さはC#:JS = 3:7程度である。
実際のコードは[GitHub](https://github.com/NagayamaToshiaki/TwitterLikeCharactersCount)をご覧いただくとして、こちらでは使い方やコードの重要な部分を解説する。
#使い方
1. HTMLヘルパーとして`CharactersCountingDivHelper`を登録する。ビューの`web.config`の`system.web.webPages.razor` > `pages` > `namespaces`要素に`<add namespace="(プロジェクト名).(ヘルパーを入れたフォルダ)"/>`でヘルパーを登録できる。
1. `BundleConfig.cs`の`RegisterBundles`関数に<br>`bundles.Add(new ScriptBundle("~/bundles/countInput").Include("~/Scripts/input/count_input.js"));`<br>を設定する。
1. ヘルパーを利用するビューにスクリプトを追加する。宗教的理由で遅延読み込みを使っているので、利用時は<br>`<script src="@(BundleTable.Bundles.ResolveBundleUrl("~/bundles/countInput"))" defer></script>`<br>と[入力する必要がある](https://stackoverflow.com/questions/16824347/how-can-i-use-the-script-defer-attribute-for-asp-mvc-4-bundles-with-scripts-rend#answer-43235055 "javascript - How can I use the script defer attribute for ASP MVC 4 Bundles with Scripts.Render - Stack Overflow")。これからのJSはdeferが当たり前になるはずなので、今の内に慣れるのが重要だと思う。
1. フォーム内で`@Html.TwitterLikeInputFor`を使用する。名前がForで終わるのはASP.NET MVC純正のHTMLヘルパーに合わせた形。

`@Html.TwitterLikeInputFor`の引数は5つ。

* `maxLength`(入力欄の最大文字数。半角基準で入力)
* `wrapperHtmlAttributes`(ラッパーのHTMLプロパティ。**idは指定しても無視される**)
* `editorHtmlAttributes`(編集箇所のHTMLプロパティ。**id、controleditableは指定しても無視される**)
* `validationHtmlAttributes`(編集箇所のHTMLプロパティ)
* `excessiveStringAttributes`(超過部分のHTMLプロパティ)

引数名に`Attributes`とあるものはいずれも`Dictionary<string, object>`で指定。これはこの手のヘルパーでよくつかわれる`Object`だとリフレクションを使わなければならないので、それを避けるという信条の問題がある。**名前付き引数で設定する**と幸せになれる。
#実装の解説
##ヘルパー(ASP.NET MVC)
オプション引数を使うことでHTML属性の設定を任意に行える。筆者の環境のC#が古いため、属性の初期値設定が面倒なことになっているが、現在は`wrapperHtmlAttributes ??= new Dictionary<string, object>();`で一発入力できる。ただ、どうせならオプション引数に直接入れられると`NullReferenceException`問題が無いのでうれしいのだが。おそらくCLIの変更も必要なので難しいと思われるが、MSには対応していただきたい。

最大文字数も（Twitter風なので意味は無いが）初期値を入力可能にしている。文字数カウントの仕様が独特なため`MaxLength`属性はあえて使用していない。つまり、文字数のバリデーションは完全にJSの担当である。

後はHtmlHelperの拡張メソッドと`TagBuilder`を使って各種要素の生成をしているだけである。

編集箇所の方は`contenteditable`を`true`にすることで直接編集可能にしている。また、`id`を指定することで、JSから読み出せるようにしている。さらに、クラスに`editor`を追加することで、編集箇所ということを分かりやすくしているので、既存HTMLとCSSにバッティングしないように注意！

属性を直接指定できる要素の他にも、文字数カウンターも生成している。

##JavaScript
Visual Studio 2015の非力かつ時代遅れ（例えば`for (const foo of bar)`に対応していない！）のIntellisense機能を利用してのJSコーディングは疲れたぜ。

大発見。hogeもしくはpiyoのどちらかで分割させて、なおかつ両方とも文字列として残したい場合、正規表現はこう書く。
`((?:hoge)|(?:piyo))`
この`?:`が無いと、例えばhogeがマッチングした際にpiyoのマッチングも検証され、見つからないためnull配列が生成されてしまう（逆も然り。説明が不正確なのはご容赦を）。**今回最大のハマりポイント！**ネットにも情報が転がっていなかったので、ちょっと自慢したい。
(2021/4/28追記)当該コードを`(hoge|piyo)`と書いても全く問題ないことに気づくが、`(?:)`を付けることで区切りが分かりやすくなる、ということでどうだろうか。
本来分割文字（CJK文字（ひらがな、カタカナ、漢字、ハングル）および空白文字）の正規表現はCJK文字の正規表現と結合したかったが、うまくいかずに繰り返して書いている。無念。

なお、[後読み](https://www.javadrive.jp/regex-basic/writing/index2.html#section4 "正規表現 | 先読みと後読みを使ったパターン")`(?<=hoge)`を使えればURLの検出が簡単になった[^1]のだが、さすがにSafariの対応はしておいた方がいいなというのと、**Twitter自身がしていることと違うだろう**ということで没に。
正規表現でその他注目なのは、`[\p{Script=Katakana}]`で**Unicodeカタカナの文字グループを一気に読みだしている**ところ。SafariやChromeが対応し、[V8の正規表現エンジンをFireFoxが導入した](https://www.publickey1.jp/blog/20/mozillav8firefox.html "Mozilla、今後はV8の正規表現エンジンをFirefoxにそのまま取り込むと表明。そのための互換レイヤを開発 － Publickey")ことで、ほとんどのWebブラウザがこの記法を使用可能となり、IE（と旧Edge）の互換性と引き換えに**`[\u]`で直接文字コードを指定する手間がかなり削減されたうえ、正規表現が分かりやすくなった**。ただし、句読点などは文字グループが見つからなかったため直接指定している。

`input`イベントの`isComposing`が`true`かどうかを調べることで、**日本語や中国語を入力時は変換確定後、アルファベットなどは入力ごとに文字数チェックが走る**ようにしている。

文字数カウントは「CJK文字」（1文字につき1）「URL」（長さにかかわらず11.5文字固定）「それ以外」（1文字につき0.5）でカウント方法を分けている。内部的には整数で扱いたいため、倍でカウントしている（おそらくTwitterもそういう風に実装している）。

最大文字数を超過した場合、文字列を分割するメソッドを走らせる。文字列を分割文字で配列に分けて、配列ごとの文字を数え、通常の文字列に追加していく。最大文字数を超過する部分に遭遇したらそこで分割位置を計算（**最大文字数と直前の文字数の差が分割位置になる**）、分割位置よりも前は通常の文字列、それ以降は超過文字列に分ける。以降の配列はすべて超過文字列に投入する。最後に、通常の文字列は編集箇所のテキストノードに、超過文字列は超過部分用の`<span>`に入れ、カウンターを赤字にする。

サブミット時に、編集箇所の内容を`input`に代入するのもJSの責務だ。編集箇所を`getElementsByClassName`で抽出した後、超過部分の`span`が存在しない編集箇所の`textContent`を`input`の`value`に入れる。超過部分が存在する場合はエラーを出すようにする。

#反省箇所
* 文字カウントはCJK文字以外の文字数をベースにしている。その方が処理が分かりやすいと思ったが、よくよく考えれば直感的ではなかった。変更はリスクが高いので、今更引き返せない。
* HTMLの要素生成に`TagBuilder`を利用しているが、HTML要素を直接入れ子にできない[^2]ので扱いが難しい。[AngleSharp](https://anglesharp.github.io/)を勉強して活用したほうが後々のためになったような気がする。
* 要素全体を囲むラッパーに`id`を指定しているが、よくよく考えたらラッパーで何かするというのも思いつかないし、余計な機能だった気がする。作った時に何を考えてたんだろう、私…
* 編集箇所のクラス名を、もう少し凝った、被りづらい名前にすべきだったと思う。
* カウンターのHTML属性を指定できた方が良かった。

と挙げてみたところ、反省箇所はおおむね設計の問題に集中している。コード自体はパフォーマンスの問題（例えば文字カウントを超過判定・仕分けの2回行っている）など思うところはあるが、ほぼほぼクリーンに書けていると思う。問題点があったらIssuesやプルリクの投稿をお願いします。

#まとめ
実装したい機能の概略は割とシンプルだったが、特にJSは考えさせられる場面が多々あり、文字カウントの方法や正規表現は難しかった。この文字カウントを実装したTwitterのエンジニアは本当に頭がいいと思った。
コードを実際に読まずに、与えられた仕様で実装するのは、エンジニアの地頭を鍛えるエクササイズとしては良いものだった。
惜しむらくは、後先考えずに設計をおろそかにしてしまったため、使いづらそうな部分が多いということか。問題点があったらIssuesやプルリクの投稿をお願いします（大事なことなので2回言いました）。
皆さんも、自分のTwitter風文字カウントを作ってほしいと思う。

#参考
コーディング及び記事作成時に参考にしたサイトを挙げる。

* [Twitter文字数カウント５ルール | URLや改行は何文字？ - 作業ロケット](https://afila0.com/twitter-character-count/)
* [javascript - How can I use the script defer attribute for ASP MVC 4 Bundles with Scripts.Render - Stack Overflow](https://stackoverflow.com/questions/16824347/how-can-i-use-the-script-defer-attribute-for-asp-mvc-4-bundles-with-scripts-rend)
* [正規表現 | 先読みと後読みを使ったパターン - Let'sプログラミング](https://www.javadrive.jp/regex-basic/writing/index2.html)
* [正規表現 \p{...} メモ - Qiita](https://qiita.com/dico_leque/items/08b6c5aa523216376b78)
* [[javascript]URLを取得する正規表現 - Qiita](https://qiita.com/nagimaruxxx/items/c2f186a2df5e32233122)
* [Mozilla、今後はV8の正規表現エンジンをFirefoxにそのまま取り込むと表明。そのための互換レイヤを開発 － Publickey](https://www.publickey1.jp/blog/20/mozillav8firefox.html)

#履歴
(2021/4/27追記)後読みを利用した正規表現の式がおかしかったので修正。
(2021/4/28追記)正規表現の書き方で判明した点があったので修正。

[^1]: 文頭にURLが来るか、CJK文字もしくは空白の直後にURLが来るというのを、後読みを利用して書くとこうなる。`/(?<=^|[\p{Script=Katakana}\p{Script=Hiragana}\p{Script=Han}\p{Script=Hangul}\u{3000}-\u{303F}\u{FE30}-\u{FE4F}]+|\s+)(https?:\/\/[-_.!~*'()a-zA-Z0-9;\/?:@&=+\$,%#]+)/gu`

[^2]: 入れ子にしたい内容を一旦テキストに変換して、入れたい`tagBuilder`の`InnerHtml`に代入するか、開始タグ・終了タグを別個に生成して入れ子にしたい内容をテキスト化する際に挟み込む。今回は後者を採用している。