5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Reactで編集できるHタグを作る

Posted at

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:1type:"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>;

image.png

本当でした(本当じゃなかったら、ここで記事終わり)。

編集中の外枠を消す

編集中の外枠のスタイルは :focusoutline です。

h1:focus {
  outline: none;
}

image.png

消えますね。

編集中カーソルの色を変える

inputタグ同様、 caret-color でカーソルの色を変えられます。

h1:focus {
  outline: none;
  caret-color: red;
}

image.png

contenteditableにおける改行処理のキャンセル

改行してみます。

image.png

↓「検証」の Elements で見てみた結果

image.png

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 します。

image.png

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 タグを置きましたが、そちらは入力できると思います。)

起きる原因としては、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 が更新された場合は再レンダリングされなくなります。

当然、複数人で編集するケース(オンラインエディタなど)は、ガンガン外部要因で値が変わりますので、
カーソル飛びまくりますし、別の対処方法を検討していく必要があります。

考察

一旦目指すものはできましたが、想定されるツッコミを考えてみます。

「ヘッダーのスタイルを適用した input タグを使えば?」

これは非常に正しい。
インタフェースを定義する上で、「見出し」のタグに「文字入力」機能をつけるなど言語道断で、
このやり方が正攻法だと思います。

おそらくこれが活きる状況としては、
**「レンダリングされたhtmlをそのまま提供する場合」**でしょうか。
今回の方法はあくまでヘッダタグを表示しているので、
レンダリングされたhtmlをそのままペロッと他人に提供してもちゃんと「見出し」としての体裁は保つはずです。

まぁあとは、
事前にどんなスタイルが適用されるか知らされず、CSS編集不可で、WYSIWYGを実装しろと言われた場合
ぐらいでしょうか。(あるかそんな状況?)

「Previewボタンを作れば?」

WYSIWYGやめろ。

終わりに

以上、WYSIWYG的なことをやってみる記事でした。
今回 contenteditable 属性を知ることができてとてもためになりました。

参考URL

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?