🉐

Slate.jsにおける画像(void)の改行・削除の処理をコントロールする

2024/02/21に公開

はじめに

以前に書いた記事ではOPEN8が提供するOpen BRAINで利用しているエディターライブラリのSlate.jsにて画像を挿入するサンプルを紹介しました。
本記事では画像を挿入したときに起きる挙動の修正の紹介をします。

Slate.jsの紹介や画像挿入のサンプルについては以下のリンクからご確認ください🙏
↓↓↓↓↓
https://zenn.dev/open8/articles/09f34438929eef

文章の間への画像挿入時における挙動の問題

Slate.jsの初期状態で画像(厳密にはvoidとなるElement)を文章の間に挿入し、その画像の箇所で改行をしたり、画像下の行で削除をした場合に意図したものとやや違う挙動をします。

動作 実際の動作 意図したいこと
画像を選択して、Enterボタンを押下 何も起きない 改行されて画像下にテキストを入力できるようになる
画像下にある文字が0文字の空行にてDeleteボタンを押下 画像が削除される 空行が削除される

2つ目の画像下にある文字が0文字の空行にてDeleteボタンを押下については間違っていないとも取れますが、実装・動作確認時には意図していない挙動と判断しました。
テキスト下の空行を削除してもテキストは削除されず空行がなくなって見栄え上の隙間が埋まることを考えると画像が消されてしまったりするのは不便ですよね...
これらの動作はSlate.jsが提供するサンプルでも確認ができますので、気になる人は確認してみてください。

https://www.slatejs.org/examples/images

実際に動作確認するのが面倒な人にはGifを用意しました。
少々分かり辛いかもしれませんが、画像の下の行にキャレットがあるのに削除されていることなどが見て取れますね。

理想的な挙動について

上記で述べた問題の修正を行うに当たって、動作の整理をします。

先ほど述べた動作は文章と文章の間にある画像の処理です。これに加えて、入力内容の最初の行に関する処理にも修正もします。
例えば、画像が一番最初に挿入されていたが上に文章を入力したくなった時などを想定しています。この時には画像の一段下にズラす(画像上に新規の行を追加する)ことをしたいと思います。

修正したいことをEnter・Delete別にまとめます。

Enterボタンを押下

条件・状態 実際の動作 修正後の動作
選択している画像が最初の行にある 何も起きない 画像が一段下がる(画像の上に空行が生まれる)
選択している画像が最初の行以外にある 何も起きない 改行されて画像下にテキストを入力できるようになる

Deleteボタンを押下

前提として入力文字が0文字の空行に関しての処理となります。

条件・状態 実際の動作 修正後の動作
入力内容全体の中で最初の行・0文字の空行である 何も起きない 空行が削除されて全体が1行詰まる
画像(voidのElement)下の行・0文字の空行である 画像が削除される 空行が削除される

画像が条件に絡んだりしますが、動作の対象や条件は行に関したものです。
画像を選択時のDelete押下は画像が削除されれますが、変だと思うような動作ではないので今回の修正の対象とはしません。

画像が最後の行にあることは考慮しないのか?

このパターンについては、以下の理由から考慮しなくても対応されることとなる。

  • 改行: 画像下に行が追加されれば問題ないので、上記した修正で対応される
  • 削除: 画像を選択時の削除処理については最初から問題ないので対応不要

キャレットや入力内容の位置に関する情報について

改行・削除をする際に最初の行であることや、画像の下の行であることを知るために、現在のキャレット(エディター内にて、「|」で表現されるカーソルのこと)や各要素や文がどこにあるかを取得します。

この章では、Slate.jsの入力内容の位置についての情報を取り合うめの型の紹介をします。
Nodeも出てきますが、以前の記事で説明しているのでここでは省きます。

Selection(Range)

エディター内のキャレットに関する情報を取り扱います。選択範囲までをカバーした作りとなっていて、anchor(範囲選択の始点)、focus(範囲選択の終点)というキーでPoint型の値を持ちます。

interface Selection {
  anchor: Point;
  focus: Point;
}

Point

テキストのNode(Leaf)が全体のどの位置にあるかというPath型の値と、その文字列のなかで何文字目にあるかと値を持ちます。

interface Point {
  path: Path;
  offset: number;
}

pathに何段目の何個目のテキスト群であるかと言った情報が配列で格納されます。offestが文字の位置です。

Path

先ほどのPointでも述べましたが、Path型はNodeが全体の中でどの位置にあるかを示しています。値としては数値の配列となっています。
また、Pointにおいてはテキストの位置を示していますが、Elementの位置を示すときにもPath型を利用します。

type Path = number[];

実画面からのサンプル

以下の状態でにあけるSelectionからPoint,Pathなど考えてみます。

テキストの状態としては

  • 「あいうえお」、「かきくけこ」の2行
  • 「うえお」は太文字

となっていますね!
こちらをeditor.childrenのデータとして表示すると以下のようになります。

// editor.childrenの中身
[
  {
    type: 'paragraph',
    children: [
      {
        text: 'あい',
        bold: false,
      },
      {
        bold: true,
        text: 'うえお',
      },
    ],
  },
  {
    type: 'paragraph',
    children: [
      {
        bold: false,
        text: 'かきくけこ',
      },
    ],
  },
];

Selection型であるeditor.selectionは以下のようになります

// editor.selectionの中身
{
  anchor: {
    path: [0, 1],
    offset: 1,
  },
  focus: {
    path: [0, 1],
    offset: 3,
  },
}

anchorに注目します。
anchor.pathの[0,1]のうち1番目の0は、editor.childrenのindexに該当し1つ目のparagraphを示しています。2番目の1は1番目のeditor.childrenで指定されたElementが持つchildrenのindexを示します。サンプルの中では、「うえお」のテキストのNodeを示しています。

offsetもこちらも配列のindexと同様に考えることになり、「う」の左側が0、右側が1になるので、キャレットはサンプルの「う」の右側にあることになります。

focusは同じpathでoffsetが違うだけなので、「うえお」のうち「お」の右側にキャレットがあることが分かります。

実装

voidは画像だけでなく、動画の埋め込みなど他の拡張したときにも利用され同じ問題が起こります。
そのためvoidのElementを対象とした修正を行います。

https://github.com/ianstormtaylor/slate/issues/3991#issuecomment-832160304

こちらの内容を参考にして作成しています。
リンク先の内容の解説に加えて、一部改良を施しています。

エディターの挙動をカスタム化する準備

Slate.jsに画像を挿入したときに何となく気がついてる方もいるかもしれませんが、Slate.jsでは改行・削除・ペーストなどのエディターに対する処理に対してオーバーライドを行うことができます。

画像(voidのElement)に対しての振る舞いをオーバーライドするための関数をはじめに用意します。

export const withCorrectVoidBehavior = (editor: Editor) => {
  const { deleteBackward, insertBreak } = editor;

  editor.insertBreak = () => {
    // TODO: 条件に合わせて改行処理を作り込む
    insertBreak();
  };

  editor.deleteBackward = unit => {
    // TODO: 条件に合わせて削除処理を作り込む
    deleteBackward(unit);
  };

  return editor;
};
  const plugins = [
    withReact,
    withImage, // NOTE: 画像挿入をできるようにしたときに、画像を取り扱うために作成した関数
    withCorrectVoidBehavior,
  ];
  const editor = plugins.reduce((editor, pluginInitializer) => {
    return pluginInitializer(editor);
  }, createEditor());

作成した関数を公式が提供しているwithReactなどと合わせて呼び出すようにしてください。
withXXXなど増えたときにwithXXX(withYYY(withZZZ(createEditor())))となってきたら読みづらいですよね?
何を使っているか分かりやすくしたいので配列化して、コード例ではそれらを順番に呼び出すようにしています。

初期設定は以上で終わりなので、今後はwithCorrectVoidBehaviorの中身について触れていきます。

voidのElementを選択時の改行処理

最初にキャレットの場所が取れない場合や範囲を選択をしている場合についてのハンドリングをしておきます。
今回の修正では範囲選択までは触れないので、範囲選択されているときは通常通りの処理が行われるようにします。

export const withCorrectVoidBehavior = (editor: Editor) => {
  const { deleteBackward, insertBreak } = editor;

  editor.insertBreak = () => {
+   // カーソル類が無い、範囲選択をしている場合は通常通りの処理を呼び出す。
+   if (!editor.selection || !Range.isCollapsed(editor.selection)) {
+     return insertBreak();
+   }
    // TODO: 条件に合わせて改行処理を作り込む
    insertBreak();
  };

  // NOTE: deleteBackwardは省略
  return editor;
};

Range.isCollapsedはSlate.jsが提供するヘルパーメソッドで、selectionのanchorとfocusの位置が同じ位置にあるかをチェックし範囲選択されていないことを判定してくれます。
範囲選択してないときにtrueとなります。

画像の箇所にキャレットがある場合に新しい行を追加する処理を追加します。


export const withCorrectVoidBehavior = (editor: Editor) => {
  const { deleteBackward, insertBreak } = editor;

  editor.insertBreak = () => {
    // カーソル類が無い、範囲選択をしている場合は通常通りの処理を呼び出す。
    if (!editor.selection || !Range.isCollapsed(editor.selection)) {
      return insertBreak();
    }

+   // キャレットの位置とそのNodeを取り出す
+   const selectedNodePath = Path.parent(editor.selection.anchor.path); //anchorとfocusはどちらでもOK
+   const selectedNode = Node.get(editor, selectedNodePath);
+
+   // キャレットの位置にあるNodeがvoidのElementだった場合、新規に1行を追加する
+   if (Element.isElement(selectedNode) && Editor.isVoid(editor, selectedNode)) {
+     Transforms.insertNodes(
+       editor,
+       {
+         type: 'paragraph',
+         children: [{ text: '' }],
+       },
+       {
+         at: editor.selection,
+       },
+     );
+
+     // 追加した最初の行にキャレットを移動させ、エディターにすぐ入力できる状態にする
+     const nextPath = Path.next(selectedNodePath);
+     Transforms.select(editor, nextPath);
+     ReactEditor.focus(editor);
+     return;
+   }

    insertBreak();
  };

  // NOTE: deleteBackwardは省略
  return editor;
};

画像はvoidのElementとして作成しているので、キャレットの位置にあるNodeをヘルパーメソッドを用いて取り出した後にvoidのElementであるか判断しています。
Transforms.insertNodesでNodeの挿入処理を行うことができ、現在の選択位置を第三引数にて指定しています。

このままだと、常に画像の下に改行されるだけで画像がエディターの一番最初の行にあるときに画像を削除しないと上にテキストを入力できなくなります。
画像の上に他のElementがあるかを確認することで最初の行か判断し、最初の行の場合は上に行を挿入するようにします。

export const withCorrectVoidBehavior = (editor: Editor) => {
  const { deleteBackward, insertBreak } = editor;

  editor.insertBreak = () => {
    // カーソル類が無い、範囲選択をしている場合は通常通りの処理を呼び出す。
    if (!editor.selection || !Range.isCollapsed(editor.selection)) {
      return insertBreak();
    }

    // キャレットの位置とそのNodeを取り出す
    const selectedNodePath = Path.parent(editor.selection.anchor.path); //anchorとfocusはどちらでもOK
    const selectedNode = Node.get(editor, selectedNodePath);

    // キャレットの位置にあるNodeがvoidのElementだった場合、新規に1行を追加する
    if (Editor.isVoid(editor, selectedNode)) {
+     // キャレットの位置にあるNodeより前にElementがある=最初の行でない
+     if (Path.hasPrevious(selectedNodePath)) {
        Transforms.insertNodes(
          editor,
          {
            type: 'paragraph',
            children: [{ text: '' }],
          },
          {
            at: editor.selection,
          },
        );

        // 追加した最初の行にキャレットを移動させ、エディターにすぐ入力できる状態にする
        const nextPath = Path.next(selectedNodePath);
        Transforms.select(editor, nextPath);
        ReactEditor.focus(editor);
+     } else {
+       Transforms.insertNodes(
+         editor,
+         {
+           type: 'paragraph',
+           children: [{ text: '' }],
+         },
+         {
+           at: [0],
+         },
+       );
+       // 追加した最初の行にキャレットを移動させ、エディターにすぐ入力できる状態にする
+       Transforms.select(editor, [0]);
+       ReactEditor.focus(editor);
+     }
      return;
    }

    insertBreak();
  };

  // NOTE: deleteBackwardは省略
  return editor;
};

Transforms.insertNodesでは最初の行に挿入したいので、atで[0]で0番目のindexを指定します。
これで改行については目的通り行えるようになりましたね!

行の削除処理

改行のときと同様に、範囲選択時やキャレットの場所がとれないときについてハンドリングしておきます。
画像前後の行の削除への取り扱いになるので、左端にない=文中として対象からは外します。

export const withCorrectVoidBehavior = (editor: Editor) => {
  const { deleteBackward, insertBreak } = editor;
  // NOTE: insertBreakは省略

  editor.deleteBackward = unit => {
+   // カーソルの位置情報の有無、カーソルが範囲選択をしている、左端でない場合は通常通りの処理を呼び出す。
+   if (!editor.selection || !Range.isCollapsed(editor.selection) || editor.selection.anchor.offset !== 0) {
+     deleteBackward(unit);
+     return;
+   }

    // TODO: 条件に合わせて削除処理を作り込む
    deleteBackward(unit);
  };

  return editor;
};

ほぼ、改行の時と一緒ですね。
選択された行について削除の実装をします。

export const withCorrectVoidBehavior = (editor: Editor) => {
  const { deleteBackward, insertBreak } = editor;
  // NOTE: insertBreakは省略

  editor.deleteBackward = unit => {
    // カーソルの位置情報の有無、カーソルが範囲選択をしている、左端でない場合は通常通りの処理を呼び出す。
    if (!editor.selection || !Range.isCollapsed(editor.selection) || editor.selection.anchor.offset !== 0) {
      deleteBackward(unit);
      return;
    }

+   // カーソルのある位置のNodeを取得・確認
+   const selectedNodePath = Path.parent(editor.selection.anchor.path);
+   const selectedNode = Node.get(editor, selectedNodePath);
+   const selectedNodeIsEmpty = Node.string(selectedNode).length === 0;
+
+   // 削除対象の行(Node)が空であること、さらに前の行(Node)があるのか確認
+   if (selectedNodeIsEmpty && Path.hasPrevious(selectedNodePath)) {
+     const prevNodePath = Path.previous(selectedNodePath);
+     const prevNode = Node.get(editor, prevNodePath);
+
+     // 削除対象の行がでvoidのElementで前行もvoidのElementだったときに、その空行が削除されて前の行と画像・動画の隙間が埋まる。
+     if (Element.isElement(prevNode) && Editor.isVoid(editor, prevNode)) {
+       Transforms.removeNodes(editor);
+       return;
+     }
+   }

    deleteBackward(unit);
  };

  return editor;
};

キャレットのあるElementが空(テキストなし)かつ上のElementがvoid(画像など)のときに、選択中のElementをTransforms.removeNodesにて削除します。
removeNodes,instertNodesなどは指定をしなければ現在のeditor.selectionにある位置のものが対象となります。

最後に画像などの上にある行を削除して、詰められるようにします。

export const withCorrectVoidBehavior = (editor: Editor) => {
  const { deleteBackward, insertBreak } = editor;
  // NOTE: insertBreakは省略

  editor.deleteBackward = unit => {
    // カーソルの位置情報の有無、カーソルが範囲選択をしている、左端でない場合は通常通りの処理を呼び出す。
    if (!editor.selection || !Range.isCollapsed(editor.selection) || editor.selection.anchor.offset !== 0) {
      deleteBackward(unit);
      return;
    }

    // カーソルのある位置のNodeを取得・確認
    const selectedNodePath = Path.parent(editor.selection.anchor.path);
    const selectedNode = Node.get(editor, selectedNodePath);
    const selectedNodeIsEmpty = Node.string(selectedNode).length === 0;

    // 削除対象の行(Node)が空であること、さらに前の行(Node)があるのか確認
    if (selectedNodeIsEmpty && Path.hasPrevious(selectedNodePath)) {
      const prevNodePath = Path.previous(selectedNodePath);
      const prevNode = Node.get(editor, prevNodePath);

      // 削除対象の行がでvoidのElementで前行もvoidのElementだったときに、その空行が削除されて前の行と画像・動画の隙間が埋まる。
      if (Element.isElement(prevNode) && Editor.isVoid(editor, prevNode)) {
        Transforms.removeNodes(editor);
        return;
      }
    }

+   // removeしたときに最終的に全ての入力内容がなくなりから配列になることを防ぐためにEditor.afterでチェックしている。全ての要素がない≠初期状態である。
+   if (selectedNodeIsEmpty && !Path.hasPrevious(selectedNodePath) && !!Editor.after(editor, editor.selection)) {
+     Transforms.removeNodes(editor);
+     return;
+   }

    deleteBackward(unit);
  };

  return editor;
};

!Path.hasPrevious(selectedNodePath)で最初の行であるか、!!Editor.after(editor, editor.selection)で自身より後ろに行があるか確認をしています。Path.hasPreviousに類似した自身より後にElementがあるかを判断するヘルパーメソッドがないので、後ろのElementを取得して有無を確認しています。
最初かつ最後のときにNodeを削除してしまうと入力値の情報が空配列になってしまい、Slate.jsで予期している初期値とは違うためバグとなります。気をつけましょう。

最初の行を消してしまうこと自体は、テキストなどでも無駄にはならないので直前の行にあるものがvoidのElementであるかのチェックはしていません。

これにて、全ての修正が完了です。
直っているか動かしてみてください!

おわりに

はじめて画像や動画など挿入して動かしてみたときにビックリすると思いますが改善できましたね。
最初はSelectionやPathなどの概念が難しいですが、慣れてくると他の動作の修正や動きの追加もできるようになってくるかと思います。
ぜひ、いろんな動作の改善に挑戦してみてください!!

参考文献

https://docs.slatejs.org/
https://github.com/ianstormtaylor/slate/issues/3991#issuecomment-832160304

OPEN8 テックブログ

Discussion