Closed7

モードレスなテキストウィジェットの React コンポーネント設計

AltechAltech

問題意識

  • WYSISYG はその定義上、編集時と表示時で同じレイアウトやスタイルになる
  • この関心をもとにコンポーネントを設計する場合、表示状態と編集状態を持つコンポーネントになる

これに対して、

  • 実装のレベルでは、普通にやると、表示時は p や div などの要素だが、クリックして編集すると form や textarea など別の要素に切り替わるということになる

しかしこうやって実装していくと、それってもはや別のコンポーネントでは?という気持ちにならなくもない。

「見た目は CSS に分離」の原則でいうと同じクラスをつけておけばなんとかなる的な考えもありそうだけど、そんなにうまくいくのかも定かでない。

同様に contentEditable も、どこまで使えるのか、React の設計と共存できるか、等自明ではない。

この辺りのベスト・プラクティスと方向性を探究していく。

AltechAltech

サンプル・アプリケーション

文字数制限カウンター付きのコメントフォーム。
複数ユーザーのコメントがリスト表示されうるが、自分のコメントの場合はクリックで編集可能。

表示中 編集中
表示中(初期状態) 編集中(初期状態)

参考:その他の例

この辺り。

Wantedly のプロフィール機能は全体的に WYSIWYG っぽい感じだが、実際のところ単純なテキスト以外のオブジェクトはクリックしたらモーダルでプロパティの一覧を編集するというフローになることも多い。

雰囲気だけ似ているパターン:

表示 編集

(ところで WYSIWYG の正確な提唱元?みたいなのってあるんだろうか)

AltechAltech

textarea を使った実装

基本方針:表示時はdiv要素にして、編集時はtexarea要素に切り替える。

以下、まあそうなるよねという普通の実装だと思うので、流し見てもらえればと思う。

受け取るプロパティと返却する仮想DOM

  • 入力である Props は、保存済みのテキスト、編集可否、ネットワークを介してテキストを保存する手続き(関数)の3つを受け取る。
  • 主要な状態として State があり、これは初期状態、編集中、保存済みの三つに分かれる。
type Status = 'CallToPost' | 'Posting' | 'Posted';

type Props = {
    initialText: string,
    isEditable: boolean,
    updateCommentText: (comment: string) => boolean,
}

const maxLength = 255;

function Comment(props: Props) {
    const [status, setStatus] = useState<Status>(props.initialText.length > 0 ? 'Posted' : 'CallToPost');

    // states and hander definition
    // ...

    if (status === 'Posting') {
        return (
            <form className="Comment">
                <textarea
                    value={text}
                    placeholder="この人との面白いエピソード"
                    onChange={onChangeHandler}
                    autoFocus
                ></textarea>
                <div className="Buttons">
                    <button className="Submit" onClick={submitClickHandler} disabled={textSize > maxLength}>送信</button>
                    <button className="Cancel" onClick={cancelClickHandler}>キャンセル</button>
                    {
                        textSize > 80 &&
                            <div className={"Indicator"}>{textSize}/{maxLength}</div>
                        }
                </div>
            </form>
        );
    } else {
        return (
            <div
                className={"Comment " + (text.length === 0 ? ' Placeholder' : '')}
                onClick={commentDivClickHandler}
            >
                { text.length === 0 ?  "この人との面白いエピソード" : text }
            </div>
        );
    }

}

コンポーネントの状態とイベント・ハンドラー

state 以外の状態と、イベント・ハンドラーを以下に掲載する。ポイントとしては、

  • フォームの内容を適宜状態として更新し、仮想DOMに反映させることで、Props と状態から DOM が一意に定まる、宣言的なプログラミングができている(React の利点)
  • 表示状態から編集状態に切り替えるために div のクリック・ハンドラーを設定している
    const [text, setText] = useState(props.initialText);
    const textSize = text.length;

    const onChangeHandler: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
        setText(e.target.value);
    }

    const submitClickHandler = () => {
        if (props.updateCommentText(text)) {
            setStatus('Posted');
        }
    };

    const cancelClickHandler: React.MouseEventHandler<HTMLButtonElement> = (e) => {
        if (text !== props.initialText && !window.confirm("Really canel?")) {
            return;
        }
        setStatus(props.initialText.length > 0 ? 'Posted' : 'CallToPost')
        setText(props.initialText);
    }

    const commentDivClickHandler = () => {
        if (props.isEditable) {
            if (status !== 'Posting') {
                setStatus('Posting');
            }
        }
    };
AltechAltech

contenteditable を使ったコンポーネントの実装

基本方針:div 要素にテキストを表示し、編集可否に応じて contenteditable 属性を当てる。

最初の誤った実装

まず最初に初めにやってしまった誤った実装を以下に紹介する。

<div contentEditable={props.isEditable}>
  { props.text }
</div>

これは div 要素の中身としてテキストを設定し、それを contentEditable にするというもの。一見良さそうに見えるが、console で以下のような警告が出る。

Warning: A component is contentEditable and contains children managed by React. It is now your responsibility to guarantee that none of those nodes are unexpectedly modified or duplicated. This is probably not intentional.

contentEditable な要素の子要素が React で管理されている(ここでは { props.text } のことを指す)ことを警告している。

事実としては、この div の中身はレンダリング時は props によって定義されるという意味で React 管理下で宣言的に動作する一方で、ブラウザーから contentEditable 機能を通してインタラクションすることで中身が書き換わるため、宣言的ではなく暗黙的な状態を持つことになる。

そのような状態が危ういという警告は尤もなので、「div の中身を完全に React の外に逃す」という方針を立てた。

最終的な実装:受け取るプロパティと返却する仮想DOM

まず大枠から。コンポーネントの入力と出力でいうと以下のようになる。

  • 入力は、保存済みのテキスト、編集可否、ネットワークを介してテキストを保存する手続き(関数)の3つを受け取る。
  • 出力は、テキスト本体 CommentText と編集時のみ現れる ControlsForEdit の二つに分かれる。
  • 主要な状態として State があり、これは初期状態、編集中、保存済みの三つに分かれる。
type Props = {
    initialText: string,
    isEditable: boolean,
    updateCommentText: (comment: string) => boolean,
}

type Status = 'CallToPost' | 'Posting' | 'Posted';
const maxLength = 255;

function Comment(props: Props) {
    const [status, setStatus] = useState<Status>(props.initialText.length > 0 ? 'Posted' : 'CallToPost');

    // state and handler definition
    // ...

    const CommentText = (
        <div
            className={"Text" + (props.isEditable && status !== 'Posting' ? ' CanEdit' : '')}
            contentEditable={props.isEditable}
            onInput={inputHandler}
            onClick={() => props.isEditable && setStatus('Posting') }
            ref={ref}
            placeholderText="この人との面白いエピソード"
        ></div>
    )

    const ControlsForEdit = (
        <div className="ControlsForEdit">
            <div className="Buttons">
                <button className="Submit" onClick={submitClickHandler} disabled={textSize > maxLength}>投稿</button>
                <button className="Cancel" onClick={cancelClickHandler}>キャンセル</button>
            </div>
            {
                textSize > 80 &&
                <div className={"Indicator"}>{textSize}/{maxLength}</div>
            }
        </div>
    )

    return (
        <div className={"Comment"}>
          { CommentText }
          { status === 'Posting' && ControlsForEdit }
        </div>
    );
}

主要な部分はもちろん、編集可能であれば contentEditable を true に設定すること。ここはシンプルだが、現在入力中のテキスト量を textSize として表示していることに留意する。

textSize という数値変数、及び inputHandlersubmitClickHandlercancelClickHandler という関数は未定義で、次に掲載する。

最終的な実装:React 外に逃した DOM との連携

大枠として、contentEditable な div 要素の中の DOM 自体は React の外で管理するが、カウンターを表示したり、「投稿」ボタンが押された時にそのデータをネットワークを介して保存することが必要になるため、適宜 onInput で最新の DOM の内容の意味解釈を React の状態に写しとる(ここでは editingText という状態を用意した)。

function Comment(props: Props) {
    const [status, setStatus] = useState<Status>(props.initialText.length > 0 ? 'Posted' : 'CallToPost');

    const [initialized, setInitialized] = useState(false);

    const [editingText, setEditingText] = useState<string>(normalizeText(props.initialText));
    const textSize = editingText.length

    const ref: React.MutableRefObject<HTMLDivElement | undefined> = useRef();
    const setTextToDom = (s: string) => { ref.current && (ref.current.innerText = s) }

    useEffect(() => {
        if (!initialized) {
            setTextToDom(normalizeText(props.initialText));
            setInitialized(true);
        }
    });

    const inputHandler = (e: React.ChangeEvent<HTMLDivElement>) => {
        setEditingText(normalizeText(e.target.innerText));
    }

    const cancelClickHandler: React.MouseEventHandler<HTMLButtonElement> = (e) => {
        if (editingText !== props.initialText && !window.confirm("Really canel?")) {
            return;
        }
        setStatus(props.initialText.length > 0 ? 'Posted' : 'CallToPost')
        setTextToDom(props.initialText);
        setEditingText(normalizeText(props.initialText));
    }

    const submitClickHandler = () => {
        if (props.updateCommentText(editingText)) {
            setStatus('Posted');
        }
    }

    // return JSX definition
    // ...
}

やっていることとしては、

  1. コンポーネントがマウントされた直後にテキストを表示すること
  2. DOM が変更された時に editingText を更新する(inputHandler
  3. 「投稿」ボタンが押された時には editingText をネットワーク経由で保存
  4. 「キャンセル」ボタンが押された時には内容を破棄するためにプロパティの initialText を DOM および editingText にセット

となる。

AltechAltech

実装方針の違いによる比較

やってみて、想定通りだった部分と、想定していなかった利点・欠点があったのでまとめる。

contentEditalbe の方が良い点

保守性

  • div と textarea の両方を用意して切り替える必要がないので冗長なコードがない
  • 上記の帰結として、表示状態と編集状態で見た目の一貫性も自明に維持される(CSSの当て方も自明)

インタラクション

  • テキストの途中などをクリックした際、普通にクリックした箇所にカーソルが行く(これが textarea だと、クリックする前は div なので単純には実現できない)
  • 改行を続けたり内容が増えれば勝手に入力エリアの高さが増えるし、減れば入力エリアの高さも減る(これが textarea だと、妙な追加実装を強いられ複雑化する)

textarea の方が良い点

保守性

  • ロジックの実装方針は自明。React の通常のレールに乗ればよく、宣言的なプログラミングができる。
  • 上記の関連で、contentEditable は DOM 要素のセット・解釈が必要(今回はセットに innerText を使ったが、複雑な場合は dangerouslySetInnerHTML も必要)

まとめ

今回のような単純なテキスト入力のコンポーネント(※1)の実装において、contentEditable を使った方が総合的に優れていると感じた。一方で、React のレールから外れていることは留意しておくべき。

※1:単純ではないテキスト入力のコンポーネントの例としては、メンション付きメッセージ入力フォームやリッチテキストエディターなどを挙げることができる。

AltechAltech

汎用コンポーネントの可能性

contentEditableを使った実装 からは、すでに DOM を触る上でのパターンが見受けられる。例えば、初期状態としての内容、onInput で内容を React 側に伝搬する作業、逆に DOM を設定し直す作業。

このようなことをまとめた lovasoa/react-contenteditable というものがあった。

自分の印象では、これは共通部分を引数化はしているが抽象化はできておらず、あまり使わない方が良いように思った。結局のところ DOM を意味解釈したり状態管理をしたり、 React の状態との整合性を取る作業は理解していなければ正しい判断ができないという点で、contenteditable を使うというのは React の抽象化を剥がすというのが本質だと感じるからだ。事実、contenteditable をそのまま使っても十分シンプルな実装は可能だった。

AltechAltech

:memo:

  • WYSIWYG というよりモードレスという方が正しいかも。
  • リッチテキストや画像などが入る可能性がある
このスクラップは2021/08/15にクローズされました