テックタッチのフロントエンドチームです。
今回はテックタッチの機能の表現の幅を広げるために WYSIWYG 機能を実装しました。 本記事では、Draft.js を用いて WYSIWYG 機能をどのように実装したか、どういう難しさがあったかなどを紹介します。
WYSIWYG とは
WYSIWYG とは、簡単にいうと、 Markdown などの独自記法を用いて、、編集画面とは別画面で文書表現(UI)の設定をするのとは違い、「ユーザが見ているままの文書を、同画面で、かつよりリッチな UI に編集できる機能」です。みんなが「ウィジウィグ」と読めるようになるまでそれなりに時間がかかりました…。 ja.wikipedia.org
Draft.js とは
WYSIWYG を実装するにあたり、いくつかのライブラリの調査を行ないました。
今回は Facebook 製である Draft.js を採用することにしました。採用理由は以下の通りです。
- テックタッチのフロントエンドでは React, TypeScript を使っているので、それらとの親和性が高いこと
- Facebook がリポジトリオーナーであり、信頼性があること
- ミニマムに導入することが前提であり、商用利用は避けたかったこと
- 拡張性に富んでおり、特殊なユースケースにも対応しやすそうなこと
- 日本語入力(IME)が利用できること
テックタッチについて
テックタッチでは、ガイドやツールチップといった機能を使ってユーザが迷わず IT ツールを操作できる環境実現のためのサービスを提供しています。
これらはエディターアプリを用いて作成することができ、プレイヤーアプリを使って利用することができます。 アプリはブラウザ拡張であるため、これらの機能はブラウザ上で利用することができます。
WYSIWYG を使って実現したこと
表現力の向上
以前の機能では、リンクや固定サイズの画像、固定サイズの見出し・本文しか扱えませんでしたが、 WYSIWYG を導入することによって以下の機能を実現することができました。
- 見出し・本文のサイズ変更
- 太字や下線などの文字装飾の利用
- 画像のサイズ変更
これらの機能追加によって、ユーザーは以前よりも作りたいデザインを実現できるようになりました。
テックタッチ独自の UI の実現やテックタッチの機能との連携
機能開発にあたって、WYSIWYG の標準的な機能だけでなく、以下のようなテックタッチ独自の要件を満たす必要がありました。
- WYSIWYG で編集する際のデザインに自由度をもたせることができる
- テキストのクリックやホバーした際の挙動を自由にカスタマイズできる
- テックタッチのガイドを起動するためのリンク(ガイドリンク)を設定することができる
実際に出来上がった UI がこちらになります。ユーザからの評判もよかったので苦労して実装した甲斐がありました。
実際に Draft.js を導入してみて
Draft.js を導入するにあたり、いくつかの工夫が必要だったので、その取り組みを紹介したいと思います。
WYSIWYG データの永続化対応
編集したテキストの値は Draft.js のフォーマットである ContentState として取得できますが、多言語化対応でテキストを翻訳する必要があることや、既存の独自フォーマットからの変換が必要ということがあり、人間に理解しやすい HTML 形式で DB に保存するようにしました。
ContentState と HTML 形式との相互変換には draftjs-convert
を利用しました。
draftjs-convert はカスタマイズ性があるため、リンクや画像なども簡単に変換することができました。
カスタムエレメント対応
テックタッチではガイドと呼ばれる機能があります。今回は Draft.js を使って「リンクを押すとガイドを再生することができるガイドリンク機能」を作成しました。
通常のリンクは a
タグで表現されますが、ガイドリンクはただのリンクではなくテックタッチ内部でのみ利用可能な特別なリンクであるため、 guide-link
タグで表現することにしました。
このタグを入出力できるようにするためには、 draftjs-convert
と Draft.js のプラグインを作る必要がありました。
画像の対応
テックタッチでは、画像を使った案内を作成することもできます。 そのため WYSIWYG でも画像の追加・表示をサポートする必要がありました。
Draft.js には image plugin が用意されており、これを利用することで画像の追加は簡単に実現することができました。 画像のリサイズにも対応しているためこの点はとても助かりました。
一方で、画像の変更はサポートされていないため実装に苦労しました。
Draft.js のデータは内部的に block と entity で分かれています。
画像の情報は entity として保持されていて、主に表示部分を担う block から entity への紐付けが行われるという挙動になっています。
そのため、画像を変更したい場合は、
変更対象のblockから対象画像のentityを特定してそのURLを書き換える
という処理を自前で実装する必要がありました。
entity の変更のみでは draft.js が変更を検知せず再描画が行われないため、擬似的な変更を block に加えて再描画を走らせると行った細工も必要でした。
また、image plugin の挙動として、画像を追加したときに画像の上部に空行が入ってしまうというものがあり、我々が実現したい挙動とはズレていたため、そのあたりの微修正も必要になりました。
サンプルコード
上記のような対応をすると以下のようなコードになりました。 HTML を与えて変更があると HTML をコールバックしてくれる WYSIWYG コンポーネントになります。 実際にはもっと複雑なのですが、雰囲気だけ掴んでもらえればと思います。
import { convertFromHTML, convertToHTML } from "draft-convert"; import { Editor, EditorState } from "draft-js"; import createImagePlugin from "@draft-js-plugins/image"; // HTML から ContentState に変換 const _convertFromHTML = convertFromHTML({ htmlToEntity: (nodeName, node, createEntity) => { if (nodeName === "guide-link") { const id = node.getAttribute("id"); const entityData = { id }; return createEntity("GUIDE-LINK", "MUTABLE", entityData); } }, }); // ContentState から HTML に変換 const _convertToHTML = convertToHTML({ entityToHTML: (entity, originalText) => { switch (entity.type) { case "LINK": return <a href={entity.data.url}>{originalText}</a>; case "IMAGE": return <img src={entity.data.src} />; case "GUIDE-LINK": { const entityData = entity.data; return <guide-link id={entityData.id}>{originalText}</guide-link>; } } return originalText; }, }); // ガイドリンクのプラグイン const plugin = { decorators: [ { strategy: (block, callback, contentState) => { const matchesEntityType = (type) => type === "GUIDE-LINK"; block.findEntityRanges((character) => { const entityKey = character.getEntity(); return ( entityKey !== null && matchesEntityType(contentState.getEntity(entityKey).getType()) ); }, callback); }, component: GuideLink, }, ], }; // WYSIWYG コンポーネント export const WYSIWYG = ({ html, onHtmlChange }) => { const [editorState, setEditorState] = useState(() => _convertFromHTML(html)); const imagePlugin = createImagePlugin(); const onChange = (editorState) => { setEditorState(editorState); onHtmlChange(_convertToHTML(editorState)); }; return <Editor plugins={[plugin, imagePlugin]} onChange={onChange} />; };
ShadowDOM 問題への対応
テックタッチは対象ページ上で 3rd party script として動作するため、対象ページのレイアウトに影響がないように、またテックタッチのレイアウトが対象ページに影響されないように、ShadowDOM を利用しています。
しかし、ShadowDOM と Draft.js を併用すると、Draft.js が正しく動作してくれないという問題がありました。 これは、React や Draft.js、 draft-js-plugins の ShadowDOM サポートが十分でないためです。 問題はいくつかありましたが、調査したところ正しく動作しない問題の多くは document に対する操作を shadowRoot に対して操作するように変更することで解消しました。
変更は patch-package で直接ライブラリに対してパッチを当てることで対応しました。 本当は Draft.js に直接コミットしたいところなのですが、まだ全体像が把握できておらず暫定対応の箇所も多く、また Draft.js 自体も 1 年以上更新が止まっているため、パッチで対応せざるを得ませんでした。
今後の課題
テスト
テックタッチではユニットテストやインテグレーションテストに Jest を利用しています。
Draft.js では contenteditable 属性を使ってエディターを実現していますが、 jsdom が contenteditable 属性をサポートしていないため、画像やリンクを挿入したりスタイルを適用したりといったテストを書くことができませんでした。なので、複数人で実際に出来上がった WYSIWYG をテストして品質を担保しました。
support for element.isContentEditable · Issue #1670 · jsdom/jsdom
IME 対応
Draft.js は未だに日本語入力 (IME) 時にいくつかの問題があります。 その問題を今後解消していくためには、パッチや OSS コミット等で対応していく必要がありそうです。 IME 関連は他のライブラリでもかなり苦労しているようで、実際に触ってみることでその辛さを体感できたのは良い経験になりました。
また、テックタッチでは ShadowDOM も併用しているため、更にチャレンジングな対応をしていくことになりそうです。
まとめ
今回は Draft.js を使った WYSIWYG の開発を紹介しました。 普段あらゆるサービスで何気なく使っている WYSIWYG ですが、実際に開発してみるとフォーカスの制御や日本語への対応など、魅力的なポイントが多いと感じました。Notion や Slack をはじめ、普段使っているサービスのようにストレスなく使える WYSIWYG を目指して引き続き改善していきたいと思います。