🤖

ProseMirrorを使って空白を可視化する

2024/09/21に公開

始めに

リッチテキストエディタのライブラリの一つにProseMirrorがあります。これはカスタマイズ性に非常に富んでおり、頑張れば普段使っているテキストエディタと同等の機能を作ることもできると思います。
テキストエディタといえば、昔TeraPadを使っていた時に空白文字を可視化できることを思い出し、それがProseMirrorで実装できるのか気になったので実際に作ってみました。

https://creating-homepage.com/archives/305

作ったもの

今回作ったものは以下です。空白の見せ方は何が良いか決めらなかったのでtofu: □bump: ␣のパターンを用意しました。実装方法もDecorationを使う方法とMarkを設定する方法があったのでそれぞれ実装してみました。

gifアニメ

ProseMirrorを使って空白を可視化する

ProseMirrorで空白を可視化する方法は2つありますが、どちらも空白に対してspanタグで括って、クラス名を付与するプラグインを作り、そのクラス名にCSSを設定します。今回は .highlight-space というクラス名を振るのでこれがある時にCSSが当たるように設定します。

.highlight-spaceに空白スタイルを設定
.ProseMirror .highlight-space {
  position: relative;
}

.ProseMirror.is-show-space.space-type-tofu .highlight-space::before {
  content: '';
  position: absolute;
  top: 1px;
  right: 1px;
  bottom: 1px;
  left: 1px;
  border: solid 1px #aaa;
}

.ProseMirror.is-show-space.space-type-bump .highlight-space::after {
  content: '';
  position: absolute;
  left: -1px;
  bottom: -4px;
  right: -1px;
  height: 4px;
  border: solid 1px #aaa;
  border-top-width: 0;
}

次から空白に .highlight-space をつける方法について説明しますが、tofuとbumpのスタイルの切り替えやスペースを可視化するかで.is-show-spaceのクラスを付与するロジックについては本題とは関係がないため割愛させていただきます。実装が気になる方は上で共有したStackBlitzのコードをご参照していただければと思います。

Decoration.inlineを使う方法

空白にスタイルを設定する一番シンプルな方法はDecoration.inlineで該当する箇所にクラス名を付与することです。以下のようにstate.docにある文書データを全て探索し、空白文字にDecoration.inlineでクラス名が設定されるようにします。

Decoration.inlineを使って空白に.highlight-spaceを設定するプラグイン
import { Plugin } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';

/**
 * 空白を可視化するプラグイン
 */
export const spaceHighlighterPlugin = () => {
  return new Plugin({
    props: {
      decorations(state) {
        const decorations: Decoration[] = [];
        state.doc.descendants((node, pos) => {
          const nodeText = node.text;
          if (!node.isText || nodeText == null) {
            return;
          }

          let match: ReturnType<RegExp['exec']>;
          const regexp = new RegExp(
            `[${[
              ' ', // 半角スペース
              ' ', // 全角スペース
              String.fromCharCode(0xa0), // &nbsp;のcharCode?
            ].join('|')}]`,
            'g'
          );
          while ((match = regexp.exec(nodeText)) != null) {
            decorations.push(
              Decoration.inline(pos + match.index, pos + match.index + 1, {
                class: 'highlight-space',
              })
            );
          }
        });

        return DecorationSet.create(state.doc, decorations);
      },
    },
  });
};

空白については半角スペースと全角スペースを探せば良さそうですが、これだけでは何故か上手くいかず &nbsp; として出力されていたスペースのcharCodeが違っていたので、対象の空白のcharCodeを調べてそれも合致するようにしています。

これで完成ですが、一つ問題がありました。日本語入力中(IME入力中)の時はクラス名の設定が入力中のテキスト全てにかかってしまいました。

色々調べてみましたが、Decoration.inlineで設定した内容の反映をする際にIME入力中があるとそれも巻き込んでしまうバグな気がしており、ライブラリが修正されないと回避できなそうな感じでした。。IMEが確定されたら正しくスタイリングされるので気にならなければこのままでも良いですが、もしこの現象を回避したい場合は次の方法で実装すると良さそうでした。

MarkTypeを設定する方法

ProseMirrorでテキストに装飾する方法は他にもあって、それはmarkをセットする方法です。該当のmarkが設定されたときに.highlight-spaceクラスが当たるように設定することでDecoration.inlineのやり方と同じように可視化することができます。

schemaに空白に設定するmarkを設定する
const schema = new Schema({
  // nodesの設定は省略
  marks: {
    space: {
      // 直後のテキスト入力でspace Markが結合されないようにする
      inclusive: false,
      parseDOM: [
        {
          tag: 'span.highlight-space',
        },
      ],
      toDOM() {
        return ['span', { class: 'highlight-space' }, 0];
      },
    },
  },
});
空白にmarkを設定するプラグイン
import { Plugin } from 'prosemirror-state';
import { MarkType } from 'prosemirror-model';
import { ReplaceStep } from 'prosemirror-transform';

/**
 * 空白にmarkを設定するプラグイン
 * @param spaceMarkType - 空白に設定したいmark
 */
export const spaceMarkPlugin = (spaceMarkType: MarkType) => {
  return new Plugin({
    appendTransaction(transactions, _, newState) {
      // 新規トランザクションの作成
      let tr = newState.tr;

      transactions.forEach((transaction) => {
        transaction.steps.forEach((step) => {
          if (!(step instanceof ReplaceStep)) {
            return;
          }

          step.slice.content.descendants((node, offset) => {
            const nodeText = node.text;
            if (!node.isText || nodeText == null) {
              return;
            }

            let match: ReturnType<RegExp['exec']>;
            const regexp = new RegExp(
              `[${[
                ' ', // 半角スペース
                ' ', // 全角スペース
                String.fromCharCode(0xa0), // &nbsp;のcharCode?
              ].join('|')}]`,
              'g'
            );
            while ((match = regexp.exec(nodeText)) !== null) {
              const start = step.from + offset + match.index;
              const end = start + match[0].length;

              // markを設定するトランザクションを追加する
              tr = tr.addMark(
                start,
                end,
                spaceMarkType.create()
              );
            }
          });
        });
      });

      // 新規のトランザクションの設定がある場合はそのトランザクションを返す
      if (tr.steps.length > 0) {
        return tr;
      }
    },
  });
};

markはIME入力中で巻き込まれることがないため空白文字のみに装飾されるようになります。ただmarkは隣接文字が同じmarkだと統合されてしまう問題があります。

これはmarkのattrsが異なっていれば統合されずに済むので、ユニークIDを振ることで回避することができます。

markにユニークIDを設定できるようにする
 const schema = new Schema({
   // nodesの設定は省略
   marks: {
     space: {
+      attrs: {
+        id: {},
+      },
       // 直後のテキスト入力でspace Markが結合されないようにする
       inclusive: false,
       parseDOM: [
         {
           tag: 'span.highlight-space',
+          getAttrs(dom) {
+            if (typeof dom === 'string') {
+              return false;
+            }
+            return {
+              id: dom.getAttribute('data-space-id'),
+            };
+          },
         },
       ],
-      toDOM() {
+      toDOM(mark) {
+        const { id } = mark.attrs;
+        return ['span', { 'data-space-id': id, class: 'highlight-space' }, 0];
-        return ['span', { class: 'highlight-space' }, 0];
       },
     },
   },
 });
ユニークIDをつけてmarkを設定する
 /**
  * 空白にmarkを設定するプラグイン
  * @param spaceMarkType - 空白に設定したいmark
  */
 export const spaceMarkPlugin = (spaceMarkType: MarkType) => {
+  let count = 0;
   return new Plugin({
     appendTransaction(transactions, _, newState) {
       // 新規トランザクションの作成
       let tr = newState.tr;

       transactions.forEach((transaction) => {
         transaction.steps.forEach((step) => {
           if (!(step instanceof ReplaceStep)) {
             return;
           }

           step.slice.content.descendants((node, offset) => {
             const nodeText = node.text;
             if (!node.isText || nodeText == null) {
               return;
             }

             let match: ReturnType<RegExp['exec']>;
             const regexp = new RegExp(
               `[${[
                 ' ', // 半角スペース
                 ' ', // 全角スペース
                 String.fromCharCode(0xa0), // &nbsp;のcharCode?
               ].join('|')}]`,
               'g'
             );
             while ((match = regexp.exec(nodeText)) !== null) {
               const start = step.from + offset + match.index;
               const end = start + match[0].length;

               // markを設定するトランザクションを追加する
               tr = tr.addMark(
                 start,
                 end,
-                spaceMarkType.create()
+                spaceMarkType.create({ id: count++ })
               );
             }
           });
         });
       });

       // 新規のトランザクションの設定がある場合はそのトランザクションを返す
       if (tr.steps.length > 0) {
         return tr;
       }
     },
   });
 };

これで統合されず個別に設定されます。ただHTMLは結構長くなってしまいましたね。。

終わりに

以上がProseMirrorを使って空白を可視化する方法でした。本来であればDecorator.inlineで問題なく動作すれば簡単に終わるのですが、IME入力中だとスタイルが巻き込まれてしまうバグがあったので回避方法を模索するのに苦労しました。最終的にMarkを設定することで期待する動作はできるようになりましたが、こちらも結構ハックしたような実装になったので保守しづらそうなコードになってしまいました。
特定の文字に装飾を当てたい時にもこのやり方が使えると思いますので、参考になれたら幸いです。

GitHubで編集を提案

Discussion