転職会議事業部エンジニアの佐藤です。
転職会議の口コミ投稿にはガイドラインがあり、ガイドラインに違反する内容が含まれた口コミを投稿しようとすると、該当する内容がハイライト表示されます。
例えばこのような内容で投稿しようとすると、
ガイドラインに違反する文字列がハイライトされます。
このような内容が含まれた口コミは削除する運用のため、そもそも投稿不可にする仕様となっています。
そのため、なぜ口コミが投稿できないのかユーザーに視覚的に知らせる文字列のハイライトは必ず提供したい機能となっています。
このハイライト機能はこれまでDraft.jsを使って実現していましたが、自前実装に置き換えました。
本記事では、置き換えをした経緯と自前での実装の詳細をご紹介します。
Draft.jsの利用をやめた経緯
Draft.jsの利用をやめた経緯ですが、大きく2つあります。
- ユーザーから口コミ投稿ができないという問い合わせを受けていた
- Draft.jsがアーカイブになった
ユーザーから口コミ投稿ができないという問い合わせを受けていた
口コミ投稿ができない旨の問い合わせが複数件あったため、カスタマーサポートチームで一次調査をしたところ一部のデバイスとIMEの組み合わせで再現しました。
起きる事象としては、IMEの変換機能を使って文字入力をした際にDraft.jsが制御しているHTMLの構造を一部改変してしまうというものでした。
一度Draft.jsが想定していない構造になってしまうとそれ以降文字が入力できなくなってしまうため、ブラウザをリロードした上で特定のデバイスとIMEの組み合わせを避けないと口コミ投稿ができない状況です。
Draft.jsがアーカイブになった
Draft.jsの開発が現在も継続していれば、issueを提出したり、場合によってはプルリクエストを作成して修正案を出すことも考えられましたが、アーカイブとなっているため不具合を解消することは困難です。
このような状況のため、別の手段でハイライト機能を提供する決断をしました。
自前実装に至るまで
Draft.jsから置き換える決断をしたので、次は代替手段を探します。
手段としては別のライブラリを使うか、自前で実装するかの大きく2つが思いつきます。
最終的には自前実装を選びましたが、ライブラリの検討もしたのでまずはその過程をご紹介します。
他のライブラリでのハイライト機能を検討
実装方法を検討するにあたり焦点にしたのは、やりたいことを少ない工数で実現できるかどうかです。
今回やりたいことは、不具合が起きないことをある程度保証した上でハイライト機能を提供することになります。
少ない工数というのは、文字通り時間をかけすぎずにハイライト機能を提供できるかどうかという意味ですが、今後のメンテナンスのしやすさも重要なので、学習コストが少ないかどうかも検討する際の材料になります。(ここでの工数は純粋に実装にかかる工数を指しており、技術選定にかかる工数は除いています。)
Lexical
上記を踏まえて、まずはライブラリ利用を前提にDraft.jsの後継であるLexicalの利用を検討しました。
後継ということもありエディタ作成の概念はDraft.jsと共通するところが多いようでしたが、転職会議事業部全体でDraft.js周りに精通しているメンバーがおらず、学習コストの高さがネックになりました。
(Draft.jsを使った実装を担当していた方はチームから抜けており、既存の実装を理解するにもDraft.jsの概念を把握する必要がありました)
また、特定のデバイスとIMEの組み合わせで不具合が起きるかどうかはプロトタイプを作って動かしてみないと確認が難しいです。詳しいメンバーがいない状態でプロトタイプを作成しようとするとどうしても時間がかかってしまうため、Lexicalの採用は見送りました。
今回提供したいハイライト機能は、リッチテキストエディタ作成ライブラリの機能をフル活用する訳ではなく一部の機能を使うのみです。かかる学習コストに対してプロダクトで活かせる度合いが少ないように感じたのも採用しなかった理由の1つになります。
その他のリッチテキストエディタ作成をサポートするライブラリ
プロトタイプの作成が比較的容易だったライブラリではプロトタイプを作成し動作確認を行いましたが、不具合を再現できていた条件とは別の条件で文字入力に不具合が生じてしまうなどがあり、採用を見送りました。
今回の不具合はデバイスとIMEの組み合わせで生じるもののためブラウザの開発者ツールでは再現できず、プロトタイプを作って実機で動かしてみて初めて結果が分かるという特徴があります。
不具合が生じた際にライブラリの実装も調査しなければいけない可能性を踏まえると、自前で実装した方が結果として工数が少なく済むのではないかという考えに至り、ここからは自前実装の方法を探ることになります。
ライブラリを使わずにハイライト機能を実装したい
代替手段を探す中でまずはライブラリ利用を検討しましたが、やりたいことに対してできることが多すぎたり、使い方を理解してからプロトタイプの作成に取りかかるため実機での動作確認までのリードタイムがどうしても長くなりがちだったりと、今回のケースにぴったりなライブラリを見つけることができませんでした。
続いては、自前で実装する方法を検討した過程をご紹介します。
自前実装の実装方針を検討する
textarea, input
テキストの入力エリアとしてまず思いつくのはtextareaやinputですが、部分的に背景色を変えるようなことは仕様上できないので、素直に使うことはできません。
CSS Custom Highlight API
このAPIはRangeで範囲指定をすることでDOMの構造を変えずにCSSを適用することができるものですが、ブラウザのサポート状況が転職会議がカバーしたい範囲を満たしおらず採用できませんでした。
↓ CSS Custom Highlight API のブラウザサポート状況
ただ、今回提供したいハイライト機能を実装するにあたってはピッタリなAPIに思ったので、将来的に採用する可能性はありそうです。
contenteditable
Draft.jsではcontenteditableが使われていたため、Draft.jsを参考にしてプロトタイプを作成しました。contenteditableはHTMLのグローバル属性で、この属性をtrueにするとユーザーはそのタグの中身を編集することができます。詳細についてはリファレンスご参照ください。
プロトタイプを動かしてみたところ、残念ながら前述した特定のデバイスとIMEの組み合わせで入力不可能になってしまう不具合と同じ事象が起きてしまい、解消の目処が立たなかったため採用には至らなかったのですが、そもそもの実装難易度が高く保守のコストが膨らみそうだったので、不具合が起きなかったとしても採用するかどうか悩ましいです。
全てを説明しようとすると別の記事が書けそうなのでここでは要点のみ解説しますが、実装時に複雑さを感じたポイントは以下の通りです。
- ハイライト対象になる文字列の位置を特定するために、contenteditable要素の内部の構造を厳密に管理する必要があること
- 適切なタイミングでHTMLの上書きをするために、イベントを複数監視する必要があること
- 入力が完了したタイミングでHTML生成をする都合上、キャレットの位置を保持しておく必要があること
- contenteditable要素の内部の構造を厳密に管理したい都合上、改行時のデフォルトの動作をさせたくないので、自前で改行処理を実装する必要があること
それぞれについて、具体例を用いて解説します。
ハイライト対象になる文字列の位置を特定するために、contenteditable要素の内部の構造を厳密に管理する必要があること
ハイライト対象になる文字列がテキスト中のどの位置にあるのか分かりやすくするために、以下のような構造で管理しようと考えました。
<div contentEditable="true"> <div data-block-offset-key="0"> <span data-span-offset-key="0"><span data-text="true">サンプルテキスト1行目あああ</span></span> </div> <div data-block-offset-key="1"> <span data-span-offset-key="0"><span data-text="true">いいいサンプルテキスト2行目</span></span> </div> </div>
※説明のために簡易的にしています
data-block-offset-key
を見れば何行目かが分かり、data-span-offset-key
でその行の何番目のspan要素なのかが分かります。
このような構造であることを前提に、各行ごとにハイライトを適用していきます。
例えば1行目のテキスト「サンプルテキスト1行目あああ
」では「あああ
」の部分がハイライト対象になるとすると、以下のような情報があればどの位置の文字列をspanで囲ってCSSを適用してあげればよいか分かりそうです。(同様に2行目も「いいい
」がハイライト対象とします)
{ blockOffsetKey: 0, start: 11, end: 14 }, { blockOffsetKey: 1, start: 0, end: 3 }
※実際はテキストを改行で区切って各行ごとにループ処理をしているのでblockOffsetKeyは自明ですが、説明のために含めています。
この情報をもとに、HTMLに変更を加えます。
<div contentEditable="true"> <div data-block-offset-key="0"> <span data-span-offset-key="0"><span data-text="true">サンプルテキスト1行目</span></span> <span data-span-offset-key="1"><span data-text="true" className="highlight">あああ</span></span> </div> <div data-block-offset-key="1"> <span data-span-offset-key="0"><span data-text="true" className="highlight">いいい</span></span> <span data-span-offset-key="1"><span data-text="true">サンプルテキスト2行目</span></span> </div> </div>
これで目的の箇所にCSSを適用することができました。
このような構造を作り出そうとした場合、ユーザーがcontenteditable要素内にテキストを入力した後にHTMLの構造を上書きする必要があります。上書きにはDocument.createElement()やReactのcreateElement()等を使って要素の生成をする必要があり、複雑さの要因の1つになっていました。
適切なタイミングでHTMLの上書きをするために、イベントを複数監視する必要があること
先ほどはハイライト処理のためにcontenteditable要素内の構造を管理する話をしましたが、HTMLを生成する関数を発火させるタイミングを、安易にinputイベントが発火したタイミングとしてしまうと何が起きるでしょうか。(contenteditable要素ではchangeイベントは発火しないのでここでは取り上げません)
inputイベントはcontenteditable要素内の値が変わったタイミングで発火します。英字入力で「t」「a」と入力した際はもちろんそれぞれの入力ごとにinputイベントが発火しますし、日本語入力で「た」と入力しようとした際にも「t」と「a」の入力ごとに発火します。
そのため、inputイベントが発火したタイミングでHTML生成処理を実行してしまうと、日本語入力で「た」と入力しようとしたのに「t」の入力の時点でHTML生成処理が動いてしまうためHTMLが上書きされてしまい、「た」ではなく「ta」になってしまいます。
これは致命的なので、日本語入力中は入力のセッションが終わるまでHTML生成処理を実行するのは待ちたいです。
(ライブラリを使ったプロトタイプ作成の際に途中で入力が確定してしまい正常に日本語が入力できない不具合にあたることがありましたが、イベント発火周りに考慮漏れがあるのかもなと思いました)
プロトタイプでは、日本語入力中であることを知るためにcompositionstartイベントとcompositionendイベントを利用しました。このイベントはそれぞれIMEのセッションが開始したタイミングと終了したタイミングで発火するので、日本語入力中かどうかを判別することができます。
これらのイベントを組み合わせてHTML生成処理を実行するタイミングを制御することで、意図しないタイミングでHTML生成処理をしてユーザーの入力を完了させてしまう挙動を防ぐことができました。
const [isComposing, setIsComposing] = React.useState(false); const handleInput = () => { // 日本語入力中は何もしない if (isComposing) return; // 日本語入力以外の場合はハイライト処理を実行する highlight(); }; const handleCompositionStart = () => { // 日本語入力が開始されたので isComposing を true にする setIsComposing(true); }; const handleCompositionEnd = () => { // 日本語入力が完了したので isComposing を false にする setIsComposing(false); // 日本語入力が終わったタイミングでハイライト処理を実行する highlight(); }; return ( <div contentEditable onInput={handleInput} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} /> );
このようにイベントを複数監視することで意図したタイミングでのみ関数を発火させることができます。これ自体はシンプルですが、ハイライト機能全体で見ると複雑さを増す要因の1つになると感じました。
入力が完了したタイミングでHTML生成をする都合上、キャレットの位置を保持しておく必要があること
普段キャレットを気にして開発することはないのですが、入力の度にcontenteditable要素の内部のHTMLを置き換えているため、何もしないと置き換えた際にキャレットが迷子になってしまいます。
キャレットに馴染みがない方向けに説明のため、以下の「サンプルテキスト」の「ル」と「テ」の間にキャレットを置いた状態で、Selection(ユーザーが選択したテキストの範囲やキャレットの現在の位置を表すオブジェクト)を取得してみます。
HTMLは以下の通りです。
<div contentEditable="true"> <div data-contents="true"> <div data-block="true" data-node-offset="0"> <div data-node-offset="0"> <span data-span-block="true" data-node-offset="0"> <span data-inner-span-offset="0">サンプルテキスト</span> </span> </div> </div> </div> </div>
Selectionの取得結果は以下の通りです(値を一部抜粋して整形しています)。
{ "anchorNode": "#text", "anchorOffset": 4, "anchorNodeParentElement": "SPAN", "anchorNodeParentElementAttributeName": "data-inner-span-offset", "anchorNodeParentElementAttributeValue": "0", "focusNode": "#text", "focusOffset": 4, "focusNodeParentElement": "SPAN", "focusNodeParentElementAttributeName": "data-inner-span-offset", "focusNodeParentElementAttributeValue": "0" }
anchorNode
が選択開始位置、focusNode
が選択終了位置です。どちらも値がtext
となっているので、data-inner-span-offset="0"
のspan要素の内容(textノード)を指しています。
anchorOffset
はanchorNode
内でのoffsetを表す値です(focusOffset
も同様)。どちらも4となっていることから、textノードの4番目の文字の後にキャレットが置かれていることが分かります。anchorとfocusのoffsetが異なる値の場合は範囲選択になります。
次にanchorNodeParentElement
、anchorNodeParentElementAttributeName
、anchorNodeParentElementAttributeValue
を見てみると、data-inner-span-offset
属性の値が0
のspan要素を指していることが分かります。
また、例で示したSelectionの取得結果には書かれていませんが、anchorNode
のparentElement(span要素)のparentElementとしてdata-node-offset="0"
のdiv要素を辿ることができ、さらに上位の親要素も辿ることができるようになっています。
このようにSelectionオブジェクトは現在位置のnodeから親の要素を辿っていけるようになっており、キャレットはSelectionの値によってどの場所に置かれているかが決まります。
では、Selectionの値を更新せずにcontenteditable要素内のHTMLを置き換えると何が起きるでしょうか。
先ほど説明した通りキャレットはSelectionの情報を元に位置が定まるので、存在しなくなった要素にキャレットを置こうとして消失してしまうのです。
そのため、ユーザーの入力後にHTML生成関数を実行する際の前処理としてSelectionを取得してキャレットの現在位置を保持おき、HTMLの置き換えをした後にあるべき場所にキャレットを移動するためにSelectionの値を更新する必要があります。
このSelectionの操作は、範囲選択した上での改行や削除、文字列のペーストなど、ユーザーの様々な入力に対して不具合が起きないようにする必要があり、実装が複雑にならざるを得ませんでした。
contenteditable要素の内部の構造を厳密に管理したい都合上、改行時のデフォルトの動作をさせたくないので、自前で改行処理を実装する必要があること
改行についても自前で実装しようとすると様々な事を考慮しなければなりません。
まずはプレーンなcontenteditable要素で改行をした場合の挙動を見てみましょう。
<div contenteditable="true">サンプルテキスト</div>
「サンプルテキスト」と入力した後に改行すると、以下のように<div><br></div>
が挿入されるようです。
<div contenteditable="true"> サンプルテキスト <div> <br> </div> </div>
今回の主目的であるハイライト機能を実装するにあたり、文字を入力した後に改行をした際は以下のような構造になっていて欲しいです。
<div contentEditable="true"> <div data-contents="true"> <div data-block="true" data-node-offset="0"> <div data-node-offset="0"> <span data-span-block="true" data-node-offset="0"> <span data-inner-span-offset="0">サンプルテキスト</span> </span> </div> </div> <div data-block="true" data-node-offset="1"> <div data-node-offset="1"> <span data-span-block="true" data-node-offset="1"> <span data-inner-span-offset="0"><br></span> </span> </div> </div> </div> </div>
上記を実現するために、まずは改行時のデフォルトの挙動をさせないようにする必要があるので、keypressイベント発火時にpreventDefault()を実行した上で自前の改行処理を実行します。
const handleKeyPress = (e) => { // 改行の場合はデフォルトの挙動をキャンセルする if (e.key === 'Enter') { e.preventDefault(); // 自前で実装した改行処理を実行する insertBreak(); } }; return ( <div contentEditable onKeyPress={handleKeyPress} /> );
改行処理の大まかな流れは以下の通りです。
- 改行処理に必要な値を取得する
- Selectionを取得して改行操作が行われた位置を特定する
- 取得した値を元に改行処理をする
- 改行をすると1行だったテキストが改行位置を起点に2行に分かれるので、改行位置前後のテキストを取得する
- 範囲選択の場合は選択箇所のテキストは削除する
- 2行分のHTMLを生成する
- 適切な位置にキャレットを移動する
- 改行をすると1行だったテキストが改行位置を起点に2行に分かれるので、改行位置前後のテキストを取得する
- contenteditable内の各要素の
data-XXX-offset
を更新する
このように、普段の開発では全く気にしないような改行時の処理を自前で実装する必要があり、特に範囲選択時の改行処理は考慮する点が多く、不具合を出さずに実装することは現実的ではありませんでした。
contenteditableを使ったハイライト機能のプロトタイプ作成のまとめ
要点のみの解説になってしまいましたが、複雑さを感じていただけたと思います。
入力内容を部分的に色付けするためにcontenteditable要素の内部の構造を制御しようとした結果、デフォルトの挙動をオフにしたことで複雑な実装をせざるを得なくなってしまいました。
このような複雑さを持たない実装をできれば良かったのですが、筆者の熟練度の問題もありシンプルにすることができませんでした…。
(前述したCSS Custom Highlight APIが使えればHTMLを触らずにハイライトが実現できるので、断然楽に実装できるはずです)
採用した案: textareaを使いつつ別の要素を上に重ねて部分的なCSS適用を実現する
こちらが最終的に採用した方針になります。
contenteditableを使った方法と比べると複雑なポイントもほとんど無いため保守の面でもコストが小さく、Draft.jsからの置き換えを検討した時点ではやりたいことに対して最良の実装方法だと感じました。
早速、実装の詳細をご紹介します。
自前実装の詳細
コンセプト
ユーザーにはtextareaに入力をしてもらい、その内容を別のdiv要素に反映します。textareaではなくdiv要素であればキャレットの考慮等をしなくて良いため内部の要素を自由に変更でき、contenteditableを使ったプロトタイプ実装時のようにspan要素等を使って部分的なテキストハイライトを実現できます。
そしてCSSを使ってtextareaの上にdiv要素を重ねる事で、textarea(とdiv)によるハイライトを実現するというものです。
簡易的ですが以下のようなイメージです。
textareaの入力内容を別の要素に反映する
まずはユーザーがtextareaに入力した内容を別の要素に反映します。
以下はReact.useStateを使った例です。
const [text, setText] = React.useState<string>(''); const handleOnChange = (e) => { setText(e.target.value); }; return ( <div className="container"> <div className="highlight">{text}</div> <textarea className="textarea" onChange={handleOnChange} /> </div> );
ハイライト処理をする
textareaに入力された内容をuseStateで保持することができたので、次はハイライト処理を実装します。
実装の流れは以下の通りです。
- ユーザーが入力したテキストを改行で分割して1行ずつ処理できるようにする
- 各行ごとにHTMLを生成する
- ハイライトするべき文字列の範囲を取得する
- 通常色の文字列とハイライトする文字列で場合分けしてspan要素で囲む
- 生成したHTMLをtextareaに重ねたdiv要素に反映する
実装例がこちらです。
※考慮すべき点が他にいくつかありますが、本筋ではない処理と型情報は省いています。
const getHighlightRanges = (text) => { // ハイライトする対象の文字列を定義 const targetWords = ['あああ', 'いいい']; // targetWordsに該当する文字列の範囲を取得 const ranges = []; targetWords.forEach((word) => { const matches = Array.from(text.matchAll(new RegExp(word, 'g'))); matches.forEach((match) => { if (match.index !== undefined) { ranges.push({ start: match.index, end: match.index + match[0].length, highlightType: 'reject', }); } }); }); return ranges; }; const createHighlightedText = (text) => { if (!text) return <div />; return text.split('\n').map((line) => { const ranges = getHighlightRanges(line); // ハイライトする範囲がない場合は通常色でそのまま表示 if (ranges.length === 0) return <div>{line}</div>; let offset = 0; const elements = []; ranges.forEach((range, index) => { // 現在のoffsetとrange.startが異なる場合は、offsetからrange.startまでを通常色span要素で囲む if (range.start !== offset) { const normalText = line.slice(offset, range.start); elements.push(<span>{normalText}</span>); offset = range.start; } // range.startからrange.endまでをハイライト色でspan要素で囲む const highlightText = line.slice(range.start, range.end); elements.push(<span className={style[range.highlightType]}>{highlightText}</span>); offset = range.end; // 残りの文字列を通常色でspan要素で囲む const nextRange = ranges[index + 1]; if (offset !== (nextRange ? nextRange.start : line.length)) { const normalText = line.slice(offset, nextRange ? nextRange.start : line.length); elements.push(<span>{normalText}</span>); offset = nextRange ? nextRange.start : line.length; } }); return <div>{elements}</div>; }); }; export const HighlightableTextArea: React.FC = () => { const [text, setText] = React.useState<string>(''); const handleOnChange = (e) => { setText(e.target.value); }; const highlightedText = createHighlightedText(text); return ( <div className="container"> <div className="highlight">{highlightedText}</div> <textarea className="textarea" onChange={handleOnChange} /> </div> ); };
textareaの上にdiv要素を重ねる
最後にCSSを使って、textareaとハイライト処理済みのdiv要素を重ねます。
※重要なプロパティのみ記載しています。
.container { position: relative; .highlight { .reject { background-color: rgba(227, 199, 72, 0.32); } } .textarea { position: absolute; background-color: transparent; color: transparent; caret-color: #000; } }
後はtextareaとdiv要素が完全に重なるように調整すれば完成です。
contenteditbleを使った場合との比較
textareaを使った実装例ではいくつか考慮すべき細かな点が残っていますが、contenteditableでの実装に比べれば複雑な点は大幅に減っています。
素のtextareaで入力を行なっているためキャレット位置の変更や自前の改行処理が不要になり、Selectionも登場しません。IMEによる不具合も回避できています。
コード量も問題なく細部まで把握できる程度に収まっています。contenteditableを使ったプロトタイプは700行ほどあり、全体感は掴めても細部を把握するのは少し時間がかかりました。
contenteditableを使った実装が優れている箇所は無く、迷いなくtextareaとdivを使った実装を採用することができました。
本番環境に反映する前に
余談ですが、Draft.jsからtextareaへの置き換えを本番環境に反映するにあたってマイナス影響が無いことを確認するためにABテストを実施しました。いきなり反映してしまっても良いのですが、何か問題があった場合の影響を少なくしたい意図と、置き換えによる口コミ投稿率などの各種数値の変動を定量的に把握したいためです。
ABテストは、口コミ投稿画面に遷移したユーザーを1 : 1でグループ分けをし、テストパターンならtextareaを使った口コミ投稿エリアを、オリジナルパターンならDraft.jsを使った口コミ投稿エリアを提供するというものです。
テスト結果の詳細は控えますが、テストパターンでネガティブな数値はほとんど無く、口コミ投稿率や口コミ検閲通過率で良い傾向が見られました。特に、企業のページに掲載できる(検閲を通過している)口コミ投稿数が約6%増加する結果となっており、ここまで増える想定は無かったので嬉しい誤算です。 ハイライト機能をDraft.jsから置き換える動機になっていた不具合を解消したことで、これまで投稿を諦めていたようなユーザーが投稿できるようになったためと推測されます。
まとめ
Draft.jsから置き換える事を決めてから実装方法を決めるまでとても遠回りをしてしまいましたが、最終的にはシンプルでメンテナンスもしやすく、最善と思える実装方法にできて良かったです。
少ない工数でやりたいことを達成するためにライブラリを利用することは強力な武器になりますが、一方で意図しない挙動が発生した際の原因調査や解消に想定以上に工数がかかってしまう場合もあることを学べました。
また、本件は通常の業務とは異なる技術投資枠で実施した個人のプロジェクトとなるため、良いと思える方法を見つけるまで自分のペースで進めることができました。
技術投資枠は社内で「技術投資10%ルール」と呼ばれているもので、エンジニアの工数の10%を、コードの改善や中長期的に導入したい技術の検証など、エンジニアが自ら判断した技術的挑戦に割り当てるルールとなっています。
技術投資10%ルールについて livesense-inc.gitbook.io
今後も技術投資枠を活用して課題解決し、プロダクトに貢献していければ思います。
最後まで読んでいただきありがとうございました!