WYSIWYG的なことをやります。
はじめに
WYSIWYG とは、
見たままのものを実際に作成出力するという言葉のWhat You See Is What You Getの頭文字をとったもの
(WYSIWYGエディター - CMS用語集 | CMS - Web Meister(ウェブマイスター) 静的コンテンツマーケティングシステムより)
とあるように、書いた文字がレイアウト等そのままに編集できることであり、
有名なOSSでは Draft.js や、 Editor.js といったものがあります。
今回はそういったものを使っていく、ではなく一部自作してみようという記事になります。
Editorjsで不満だった点
端的に言えば、「Editorjs は自由すぎた」というのが不満な点でした。
Editorjs のデータ構造
Editorjsでは、文章を以下のようなJSONで表します。
{
"time": "<timestamp>",
"blocks": [ "<block>" ],
"version": "<version>"
}
blocksの中では、各要素がブロックという単位で記述されていて、
type: "header"
なブロックは見出しを表し、 type: "paragraph"
ではそのブロックは段落を表します。
それぞれのブロックの type によって、 hタグ
や pタグ
に置換されてレンダリングされ、
それらにCSSがあたることで WYSIWYG を実現しています。
Editorjs は「文書」を書ける
Editorjs は複数ブロックで構成される「文書」のエディタです。
ユーザーが望むままにブロック(見出しや段落)を追加・編集することができます。
具体的にはブロック内でエンターキーを押すと次の type: "paragraph"
なブロックが挿入され、
そこにカーソルが移りますし、
気が変わったら type: "header"
なブロックを paragraphに変更することができます。(逆もしかり)
これが Editorjs のいいところですが、
一方で一定のフォーマットで入力させたい場合には不利に働く場合があります。
ユーザーが何を意図したブロックなのかわからない
ただの「ブロックの配列」になってしまうと、そこにはユーザーのどんな意図が含まれていたのか、
わからなくなってしまいます。「文書のタイトルは level:1
の type:"header"
で書くこと」といった
ルールを決めるケースもあるのかもしれません。
(それはそれでいいのかもしれない)
↓のように、metadataを追加できるようにする計画もあるにはありそうですが
https://github.com/codex-team/editor.js/issues/727
今回書く内容
というわけで、今回は編集可能なヘッダタグを作成してみよう、という記事になります。
実装
HTML5の contenteditable
属性を使用していきます。
MDN にドキュメントがあります。
contenteditable - HTML: HyperText Markup Language | MDN
ヘッダータグを編集可能にする
contenteditable="true"
を指定したタグは編集可能になるとありますが、本当でしょうか?
() => <h1 contentEditable>h1#contentEditable</h1>;
本当でした(本当じゃなかったら、ここで記事終わり)。
編集中の外枠を消す
編集中の外枠のスタイルは :focus
の outline
です。
h1:focus {
outline: none;
}
消えますね。
編集中カーソルの色を変える
input
タグ同様、 caret-color
でカーソルの色を変えられます。
h1:focus {
outline: none;
caret-color: red;
}
contenteditableにおける改行処理のキャンセル
改行してみます。
↓「検証」の Elements で見てみた結果
h1
タグの中のdiv
ナンデ?!
contenteditable時の仕様についてはブラウザによって異なるようで、その闇は↓の記事にまとまっています。
noteと"contenteditable"|ct8ker|note
これ読むと、WYSIWYGエディタのOSSには足を向けて眠ることができなくなりますね・・・
今回は、改行できないヘッダーを作成しよう、
というわけでエンター押したらフォーカス外すようにしましょう。
function H1Editable({ text }) {
const ref = useRef(null);
return (
<h1
contentEditable
ref={ref}
onKeyDown={(e) => {
if (e.which === 13) ref.current.blur();
}}
>
{text}
</h1>
);
}
いいぞ。
変更検知を受け取る
input
タグではないので、onChange
は使用できません。代わりにonInput
を使用していきます。
function H1Editable({ text, onChange }) {
const ref = useRef(null);
return (
<h1
contentEditable
ref={ref}
onInput={() => {
onChange({ target: { value: ref.current.innerText } });
const oSel = document.getSelection();
console.log(oSel.getRangeAt(0));
}}
onKeyDown={(e) => {
if (e.which === 13) ref.current.blur();
}}
>
{text}
</h1>
);
}
function App() {
return (
<div className="App-header" style={{ padding: 20 }}>
<H1Editable onChange={(e) => console.log(e.target.value)}>h1#contentEditable</H1Editable>
</div>
);
}
propsに onChange
を入れて、親のコンポーネントでハンドルします。とりあえず console.log
します。
contentとEditableの間に hoge と入力してみました。
ちゃんとconsole.logされていますね
親コンポーネントの state を使用する
ここで、以下のように App の state で入力内容を持ってみましょう。
function App() {
const [h1Content, setH1Content] = useState("h1#contentEditable");
const handleChange = useCallback(
(e) => {
setH1Content(e.target.value);
},
[setH1Content]
);
return (
<div className="App-header" style={{ padding: 20 }}>
<H1Editable onChange={handleChange} text={h1Content} />
<input onChange={handleChange} type="text" value={h1Content}></input>
</div>
);
}
すると、入力が正しくできなくなることがわかると思います。
(比較用に input タグを置きましたが、そちらは入力できると思います。)
伝わらないモノマネ
— いしぐ (@yoh1496) June 13, 2020
「confluenceで複数人で同じ行を編集してしまったとき」 pic.twitter.com/1u6OLDirBz
起きる原因としては、propsが変わるたびに h1Editable
の再レンダリングが走り、
結果としてカーソルが初期位置(0文字目の位置)に移動してしまうことが挙げられます。
今回は暫定対処ではありますが、再レンダリングを抑制する方向で対処してみたいと思います。
shouldComponentUpdate で再レンダリング抑止
ここまでは FunctionComponent として実装してきましたが、 shouldComponentUpdate が使いたいので、
ClassComponent で行きます。
class H1Editable extends React.Component {
constructor(props) {
super(props);
this.ref = React.createRef();
}
shouldComponentUpdate(nextProps) {
return nextProps.text !== this.ref.current.innerText;
}
render() {
return (
<h1
contentEditable
ref={this.ref}
onInput={() => {
this.props.onChange({
target: { value: this.ref.current.innerText },
});
}}
onKeyDown={(e) => {
if (e.which === 13) this.ref.current.blur();
}}
>
{this.props.text}
</h1>
);
}
}
やってることはほぼ変わりませんが、
ポイントは nextProps.text !== this.ref.current.innerText
であることを判定して、
もし false
ならば更新なしとみなし再レンダリングを行わず、 true
ならば再レンダリングを行います。
こうすることで、何らかの原因で親コンポーネントの h1Content
に更新があった場合は再レンダリングされますが、
h1Editable
自体が原因となって h1Content
が更新された場合は再レンダリングされなくなります。
できた。shouldComponentUpdateか。 pic.twitter.com/sLnRbuShsG
— いしぐ (@yoh1496) June 13, 2020
当然、複数人で編集するケース(オンラインエディタなど)は、ガンガン外部要因で値が変わりますので、
カーソル飛びまくりますし、別の対処方法を検討していく必要があります。
考察
一旦目指すものはできましたが、想定されるツッコミを考えてみます。
「ヘッダーのスタイルを適用した input タグを使えば?」
これは非常に正しい。
インタフェースを定義する上で、「見出し」のタグに「文字入力」機能をつけるなど言語道断で、
このやり方が正攻法だと思います。
おそらくこれが活きる状況としては、
**「レンダリングされたhtmlをそのまま提供する場合」**でしょうか。
今回の方法はあくまでヘッダタグを表示しているので、
レンダリングされたhtmlをそのままペロッと他人に提供してもちゃんと「見出し」としての体裁は保つはずです。
まぁあとは、
事前にどんなスタイルが適用されるか知らされず、CSS編集不可で、WYSIWYGを実装しろと言われた場合
ぐらいでしょうか。(あるかそんな状況?)
「Previewボタンを作れば?」
WYSIWYGやめろ。
終わりに
以上、WYSIWYG的なことをやってみる記事でした。
今回 contenteditable
属性を知ることができてとてもためになりました。