筆者は社内SE業務で、Twitter風の文字超過をハイライトする入力欄を作成した。
その成果をここに共有する。
どうせならQiitaで投稿できるように、徹底的にTwitterを真似てしまえということで、Twitterの文字計測をそのままコードに落とし込んだ(社内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をご覧いただくとして、こちらでは使い方やコードの重要な部分を解説する。
#使い方
- HTMLヘルパーとして
CharactersCountingDivHelper
を登録する。ビューのweb.config
のsystem.web.webPages.razor
>pages
>namespaces
要素に<add namespace="(プロジェクト名).(ヘルパーを入れたフォルダ)"/>
でヘルパーを登録できる。 -
BundleConfig.cs
のRegisterBundles
関数にbundles.Add(new ScriptBundle("~/bundles/countInput").Include("~/Scripts/input/count_input.js"));
を設定する。 - ヘルパーを利用するビューにスクリプトを追加する。宗教的理由で遅延読み込みを使っているので、利用時は
<script src="@(BundleTable.Bundles.ResolveBundleUrl("~/bundles/countInput"))" defer></script>
と入力する必要がある。これからのJSはdeferが当たり前になるはずなので、今の内に慣れるのが重要だと思う。 - フォーム内で
@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文字の正規表現と結合したかったが、うまくいかずに繰り返して書いている。無念。
なお、後読み(?<=hoge)
を使えればURLの検出が簡単になった1のだが、さすがにSafariの対応はしておいた方がいいなというのと、Twitter自身がしていることと違うだろうということで没に。
正規表現でその他注目なのは、[\p{Script=Katakana}]
でUnicodeカタカナの文字グループを一気に読みだしているところ。SafariやChromeが対応し、V8の正規表現エンジンをFireFoxが導入したことで、ほとんどの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を勉強して活用したほうが後々のためになったような気がする。 - 要素全体を囲むラッパーに
id
を指定しているが、よくよく考えたらラッパーで何かするというのも思いつかないし、余計な機能だった気がする。作った時に何を考えてたんだろう、私… - 編集箇所のクラス名を、もう少し凝った、被りづらい名前にすべきだったと思う。
- カウンターのHTML属性を指定できた方が良かった。
と挙げてみたところ、反省箇所はおおむね設計の問題に集中している。コード自体はパフォーマンスの問題(例えば文字カウントを超過判定・仕分けの2回行っている)など思うところはあるが、ほぼほぼクリーンに書けていると思う。問題点があったらIssuesやプルリクの投稿をお願いします。
#まとめ
実装したい機能の概略は割とシンプルだったが、特にJSは考えさせられる場面が多々あり、文字カウントの方法や正規表現は難しかった。この文字カウントを実装したTwitterのエンジニアは本当に頭がいいと思った。
コードを実際に読まずに、与えられた仕様で実装するのは、エンジニアの地頭を鍛えるエクササイズとしては良いものだった。
惜しむらくは、後先考えずに設計をおろそかにしてしまったため、使いづらそうな部分が多いということか。問題点があったらIssuesやプルリクの投稿をお願いします(大事なことなので2回言いました)。
皆さんも、自分のTwitter風文字カウントを作ってほしいと思う。
#参考
コーディング及び記事作成時に参考にしたサイトを挙げる。
- Twitter文字数カウント5ルール | URLや改行は何文字? - 作業ロケット
- javascript - How can I use the script defer attribute for ASP MVC 4 Bundles with Scripts.Render - Stack Overflow
- 正規表現 | 先読みと後読みを使ったパターン - Let'sプログラミング
- 正規表現 \p{...} メモ - Qiita
- [javascript]URLを取得する正規表現 - Qiita
- Mozilla、今後はV8の正規表現エンジンをFirefoxにそのまま取り込むと表明。そのための互換レイヤを開発 - Publickey
#履歴
(2021/4/27追記)後読みを利用した正規表現の式がおかしかったので修正。
(2021/4/28追記)正規表現の書き方で判明した点があったので修正。
-
文頭に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
↩ -
入れ子にしたい内容を一旦テキストに変換して、入れたい
tagBuilder
のInnerHtml
に代入するか、開始タグ・終了タグを別個に生成して入れ子にしたい内容をテキスト化する際に挟み込む。今回は後者を採用している。 ↩