📝

ProseMirrorでautolinkするプラグインを作成

2023/01/07に公開1

始めに

ProseMirrorを使っている際に、https://~みたいなテキストを自動でリンクっぽい見た目にしたくて、「ProseMirror autolink」と検索するとなんとなくそれっぽい記事が見つかります。しかし、よく検証すると自分が期待した挙動にはならず、最終的には自前でプラグインを作ることになりましたので、その辺についてまとめました。

今回作ったもの

今回作ったものを先に貼ります。この挙動が良くて詳細の実装が気になった方は是非続きをご覧ください。

余談: InputRuleの使用を断念した理由

検索した記事にはInputRuleというプラグインを使ってやるみたいな内容が書かれていました。正規表現を指定して、そのテキストがきた時に何かしら処理を加えるというプラグインでとても良さそうですが、致命的な問題がありました。それは直前に入力したテキストを取得できないという問題です。
handleTextInputの中でrunというメソッドを呼び出しており、この時点ではtextが渡っているのですが、肝心のhandlerを呼ぶときにはマッチした正規表現とテキストの位置しか渡しておらず、handler内では何が入力されたかの判断ができません。handlerで新しいトランザクションを生成すると直前に入力したテキストが入力されない状態になり、かなり致命的な状態になります。おそらくこれは単純にテキストからテキストに変換するために作られたプラグインなんでしょうね。。また、そもそもこれはキー入力時のみであり、コピペなどには対応されていないため、その辺も要件に合いませんでした。

https://github.com/ProseMirror/prosemirror-inputrules/blob/1.2.0/src/inputrules.ts#L89-L103

実装方法

Prosemirrorにはmarkというものでテキストに装飾ができるため、autolinkというmarkを用意し、該当するテキストにこのmarkを付与してリンクを表現します。marksを定義する際に、上から順番にラップされている形になり、下にいると他のmarkによってタグがぶつ切りされる可能性があるため一番上に定義します。

autolinkのmarkを定義
const schema = new Schema({
  // nodesは省略
  marks: {
    // 他のmarkの装飾でリンクが途切れないように一番上で定義する
    autolink: {
      attrs: {
        href: {}
      },
      // 末尾で追加させない(追加すべきテキストかはプラグイン側で判断する)
      inclusive: false,
      parseDOM: [
        {
          tag: "a[href]",
          getAttrs(dom) {
            if (typeof dom === "string") {
              return false;
            }
            return {
              href: dom.getAttribute("href")
            };
          }
        }
      ],
      toDOM(mark) {
        const { href } = mark.attrs;
        return ["a", { href, target: "_blank" }, 0];
      }
    },
    bold: {
      parseDOM: [{ tag: "b" }],
      toDOM() {
        return ["b", 0];
      }
    }
  }
});

初期表示時のautolink適応

まずはこのautolinkのmarkを最初にセットするdocに対して適応したいと思います。動作確認用として初期値は以下のようなものにします。

autolink適応させるdoc
const doc = schema.node("doc", null, [
  schema.node("paragraph", null, [schema.text("Autolinkテスト")]),
  schema.node("paragraph", null, [
    schema.text("https://"),
    schema.text("google", [schema.marks.bold.create()]),
    schema.text(".com")
  ]),
  schema.node("paragraph", null, [
    schema.text(
      "テキストに囲われたhttps://google.comリンク 2つ目のリンクhttps://facebook.com"
    )
  ]),
  schema.node("paragraph", null, [
    schema.text("間違えたautolink", [
      schema.marks.autolink.create({ href: "https://hogehoge.com" })
    ])
  ])
]);

このdocをそのままProseMirrorに入れると以下のようになります。

このキャプチャから、autolinkするには以下のことをする必要があることが分かります。

  • 他の装飾をまたがってリンク対象になるテキストはautolink markを付与する
  • リンク対象ではないテキストはautolink markを取り除く

まず最初にautolink markを付与する処理の方を書きます。マッチした範囲にschema.marks.autolink.create({ href: 'https://~' })みたいにするのですが、このautolinkだったりcreate({ href: 'https://~' })部分に汎用性を持たせるために、autolinkMarkType, createAutolinkAttrsという引数で渡すようにしています。更に、次セクションで説明する入力直後にautolink付与する処理と共通化したいため、引数にはtargetRangesを渡して検索範囲を指定できるようにします。デフォルトはdocumentの全範囲です。
処理中にあるgetTextRangesの具体的な実装は次で説明しますが、返り値はテキストだけに絞ったものが分割されたものになります。Prosemirrorでは改行nodeなど、テキスト以外の要素も存在し、今回はそれを跨いでautolink markを付与することはあり得ないためテキストブロックのものだけ抽出するイメージです。今回の初期値だと['Autolinkテスト', 'https://google.com', 'テキストに囲われたhttps://google.comリンク 2つ目のリンクhttps://facebook.com', '間違えたautolink']が取得できるようなSelectionRangeが返ってきます。
テキストさえ取得できればあとは正規表現でマッチするテキストを検索して、マッチしたらその範囲をtr.addMarkしてautolink markを付与します。

autolinkを適応するトランザクションを追加する
const URL_REG_EXP = /https?:\/\/[\w!?/+\-_~=:;.,*&@#$%()'[\]]+/;

/**
 * autolinkを適応するトランザクションを追加する
 * @param tr - トランザクション
 * @param autolinkMarkType - autolinkを設定するmark
 * @param createAutolinkAttrs - autolinkにURLをセットするためのattrsを生成するメソッド
 * @param targetRanges - 適応探索範囲
 */
export const appendTransactionForApplyAutolink = <Tr extends Transform>(
  tr: Tr,
  autolinkMarkType: MarkType,
  createAutolinkAttrs: CreateAutolinkAttrs,
  targetRanges: SelectionRange[] = [
    new SelectionRange(tr.doc.resolve(0), tr.doc.resolve(tr.doc.nodeSize - 2))
  ]
) => {
  let newTr = tr;
  const doc = tr.doc;

  const textRanges = targetRanges
    .map((targetRange) => {
      return getTextRanges(doc, targetRange);
    })
    .flat();

  textRanges.forEach((textRange) => {
    const { $from, $to } = textRange;
    const text = doc.textBetween($from.pos, $to.pos, null, "\ufffc");
    const matches = Array.from(text.matchAll(new RegExp(URL_REG_EXP, "g")));
    newTr = matches.reduce((tr, match) => {
      const url = match[0];
      const startOffset = $from.pos + (match.index || 0);
      return tr.addMark(
        startOffset,
        startOffset + url.length,
        autolinkMarkType.create(createAutolinkAttrs(url))
      );
    }, newTr);
  });

  return newTr;
};

getTextRangesの実装は以下のようになります。nodesBetweenでテキストブロックも含めて全Nodeブロックが順番に取得できるため、あとは愚直にテキストブロックの開始から終了までの範囲をSelectionRangeに登録します。

getTextRangesの実装
/**
 * 探索範囲周辺のドキュメントでテキストの範囲を取得する
 * @param doc - ドキュメント
 * @param targetRange - 探索範囲
 */
const getTextRanges = (doc: ProseMirrorNode, targetRange: SelectionRange) => {
  const ranges: SelectionRange[] = [];

  let startPos: number | null = null;
  let endPos: number | null = null;
  doc.nodesBetween(targetRange.$from.pos, targetRange.$to.pos, (child, pos) => {
    // テキストブロックの場合
    if (child.isText) {
      if (startPos == null) {
        startPos = pos;
        endPos = pos + child.nodeSize;
      } else if (endPos != null) {
        endPos += child.nodeSize;
      }
      return;
    }

    // テキストブロックじゃないとき
    if (startPos != null && endPos != null) {
      ranges.push(
        new SelectionRange(doc.resolve(startPos), doc.resolve(endPos))
      );
      startPos = null;
      endPos = null;
    }
  });
  // 連続するテキストブロックチェック中にループを抜けた場合は最後のテキスト範囲を登録する
  if (startPos != null && endPos != null) {
    ranges.push(new SelectionRange(doc.resolve(startPos), doc.resolve(endPos)));
    startPos = null;
    endPos = null;
  }

  return ranges;
};

次は除外の方を実装します。汎用性と次セクションで説明する入力中の対応も考慮してautolinkMarkTypetargetRangesは引数で渡すようにします。getMarkRangesの詳細な実装はこの後説明しますが、引数に渡したmarkが適応されている範囲を取得するものになります。今回の初期値の例だと['間違えたautolink']が取得できるようなSelectionRangeが返ってきます。あとはこのテキストが期待するURLにマッチするテキストか確認して、そうでない場合はtr.removeMarkを実行してmarkを取り除きます。

リンク対象じゃないテキストからautolink markを除外するトランザクションを作成
/**
 * autolinkに適していないテキストになっている場合はautolinkのmarkを削除するトランザクションを追加する
 * @param tr - トランザクション
 * @param autolinkMarkType - autolinkを設定しているmark
 * @param targetRanges - 探索範囲
 */
export const appendTransactionForRemoveMissMatchedAutolink = <
  Tr extends Transform
>(
  tr: Tr,
  autolinkMarkType: MarkType,
  targetRanges: SelectionRange[] = [
    new SelectionRange(tr.doc.resolve(0), tr.doc.resolve(tr.doc.nodeSize - 2))
  ]
) => {
  let newTr = tr;
  const doc = tr.doc;

  const markRanges = targetRanges
    .map((targetRange) => {
      return getMarkRanges(doc, autolinkMarkType, targetRange);
    })
    .flat();

  markRanges.forEach((markRange) => {
    const text = doc.textBetween(
      markRange.$from.pos,
      markRange.$to.pos,
      null,
      "\ufffc"
    );
    if (URL_REG_EXP.test(text)) {
      return;
    }
    // URL形式にマッチしなかった場合はautolinkを外す
    newTr = newTr.removeMark(
      markRange.$from.pos,
      markRange.$to.pos,
      autolinkMarkType
    );
  });

  return newTr;
};

getMarkRangesの実装は以下のようになります。getTextRangesと同じようにまずはdoc.nodesBetweenでnodeブロックのリストを取得します。ここから各ブロックをmarkType.isInSetで引数に渡したmarkを持っているかチェックします。マッチした場合はこれより先のブロックも同じmarkを持っているかをチェックしていくのですが、この時に属性も同じものを持っているmarkをチェックするようにします。同じautolinkであっても{ href: 'https://google.com' }の場合と{ href: 'https://facebook.com' }みたいに違う場合がありますからね。この確認はわざわざ属性値を見なくても、マッチした時に取得したmarkを使ってチェックすると自然とその確認も行われるのでそっちでチェックするようにします。あとはgetTextRangesと同じようにマッチしている開始と終了を取得してSelectionRangeにまとめます。

getMarkRangesの実装
/**
 * 探索範囲周辺のドキュメントでmarkTypeを持つ範囲を取得する
 * @param doc - ドキュメント
 * @param markType - 探索対象のmarkType
 * @param targetRange - 探索範囲
 */
const getMarkRanges = (
  doc: ProseMirrorNode,
  markType: MarkType,
  targetRange: SelectionRange
) => {
  const ranges: SelectionRange[] = [];

  let startPos: number | null = null;
  let endPos: number | null = null;
  /** マッチ中のマーク */
  let matchingMark: Mark | null = null;
  doc.nodesBetween(targetRange.$from.pos, targetRange.$to.pos, (child, pos) => {
    // まだマッチし始めていないとき
    if (matchingMark == null || startPos == null || endPos == null) {
      const matchedMark = markType.isInSet(child.marks);
      if (matchedMark == null) {
        return;
      }
      // マッチしたらマッチ状態にする
      matchingMark = matchedMark;
      startPos = pos;
      endPos = pos + child.nodeSize;
      return;
    }

    // マッチしている場合は次のNodeも同じMarkか確認する
    if (matchingMark.isInSet(child.marks)) {
      endPos += child.nodeSize;
      return;
    }
    // マッチが終了したら範囲を登録する
    ranges.push(new SelectionRange(doc.resolve(startPos), doc.resolve(endPos)));
    startPos = null;
    endPos = null;
  });
  // マッチチェックが終了せずにループが終了した場合は最後の範囲を登録する
  if (startPos != null && endPos != null) {
    ranges.push(new SelectionRange(doc.resolve(startPos), doc.resolve(endPos)));
    startPos = null;
    endPos = null;
  }

  return ranges;
};

2つのトランザクションメソッドを初期設定時に呼び出してからセットする

あとはこれらのメソッドをProseMirror初期化時に呼んであげればOKです。ただ汎用性を持たせてmarkTypeやcreateAutolinkAttrsを次のセクションで説明する方でも渡す必要が出て少し使いづらさが出てしまうためcreateAutolinkPluginKitというメソッドでラップします。この中でappendTransactionForApplyAutolinkappendTransactionForRemoveMissMatchedAutolinkを呼ぶメソッドを用意します。

/**
 * http:// または https:// で始まるURLをリンク形式に自動で設定するプラグイン一式を返す
 * @param autolinkMarkType - autolinkを設定するmark
 * @param createAutolinkAttrs - autolinkにURLをセットするためのattrsを生成するメソッド
 */
export const createAutolinkPluginKit = (
  autolinkMarkType: MarkType,
  createAutolinkAttrs: CreateAutolinkAttrs
) => {
  return {
    /**
     * 初期のdocにautolinkを適応させる
     * @param doc - ドキュメント
     */
    applyAutolinkToDoc: (doc: Node) => {
      return appendTransactionForRemoveMissMatchedAutolink(
        appendTransactionForApplyAutolink(
          new Transform(doc),
          autolinkMarkType,
          createAutolinkAttrs
        ),
        autolinkMarkType
      ).doc;
    },
    // 次セクションでプラグインも用意する
    // plugin: ~
  };
};

あとはこれを使って初期Stateを生成するときに呼び出したら完成です。

const autolinkPluginKit = createAutolinkPluginKit(
  schema.marks.autolink,
  (url) => ({ href: url })
);

const state = EditorState.create({
  doc: autolinkPluginKit.applyAutolinkToDoc(doc),
  schema,
  // pluginsは省略
});

現時点で実行すると以下のようなキャプチャが出ると思います。

入力中のautolink適応

入力中の対応はPluginにあるappendTransactionというメソッドを拡張することにします。このメソッドは今までのtransactionsを取得でき、どこが変更されたかを取得することができるため、その周辺のテキストだけ付与したり除外したりすることで目的が達成できそうです。
その辺の内容を実装したコードが以下になります。combineTransactionStepsでtransactionsを1つのtransactionにまとめて、getChangedRangesで変更があった範囲を取得します。この範囲を前のセクションで説明したappendTransactionForApplyAutolinkなどに渡せば良いのですが、1つ問題があります。このままだと変更したテキスト前後まで見れない場合があります。https://の後にgを入力した場合、変更範囲はgだけですが、検索対象としてはhttps://gまで見て欲しいです。なので取得された変更範囲から、更に広げてNodeブロック(基本的には行ブロック)の先頭から終端までに変更します。これで検索漏れを防ぐことができます。

autolinkPlugin.ts
/**
 * http:// または https:// で始まるURLをリンク形式に自動で設定するプラグイン
 * @param autolinkMarkType - autolinkを設定するmark
 * @param createAutolinkAttrs - autolinkにURLをセットするためのattrsを生成するメソッド
 */
export const autolink = (
  autolinkMarkType: MarkType,
  createAutolinkAttrs: CreateAutolinkAttrs
) => {
  return new Plugin({
    appendTransaction(transactions, oldState, newState) {
      // docに変化がないトランザクションの場合は何もしない
      const isDocChanges =
        transactions.some((transaction) => transaction.docChanged) &&
        !oldState.doc.eq(newState.doc);
      if (!isDocChanges) {
        return null;
      }

      // transaction操作をまとめて、変更された周辺部分だけautolinkチェックする
      const transform = combineTransactionSteps(oldState.doc, transactions);
      const changedRanges = getChangedRanges(transform);
      const targetRanges = changedRanges.map((changedRange) => {
        const { $from, $to } = changedRange.newRange;
        const doc = $from.doc;
        // 行の先頭から終端まで範囲を広げる
        return new SelectionRange(
          doc.resolve($from.pos - $from.parentOffset),
          doc.resolve($to.pos - $to.parentOffset + $to.parent.nodeSize - 1)
        );
      });

      const tr = appendTransactionForRemoveMissMatchedAutolink(
        appendTransactionForApplyAutolink(
          newState.tr,
          autolinkMarkType,
          createAutolinkAttrs,
          targetRanges
        ),
        autolinkMarkType
      );

      return tr.step.length > 0 ? tr : null;
    }
  });
};

combineTransactionStepsgetChangedRangesは多少改変はしてますが、基本はこちらのコードを参考に実装しました。

https://github.com/ueberdosis/tiptap/blob/v2.0.0-beta.209/packages/core/src/helpers/combineTransactionSteps.ts
https://github.com/ueberdosis/tiptap/blob/v2.0.0-beta.209/packages/core/src/helpers/getChangedRanges.ts

あとはこのプラグインをPluginKitに含めて、ProseMirrorにセットしてあげれば完成です。

 export const createAutolinkPluginKit = (
   autolinkMarkType: MarkType,
   createAutolinkAttrs: CreateAutolinkAttrs
 ) => {
   return {
     // 省略
+    /**
+     * Prosemirrorに設定するプラグイン
+     */
+    plugin: autolink(autolinkMarkType, createAutolinkAttrs)
   };
 };
 const state = EditorState.create({
   doc: autolinkPluginKit.applyAutolinkToDoc(doc),
   schema,
   plugins: [
     // 他プラグインは省略
+    autolinkPluginKit.plugin
   ]
 });

余談

autolinkのmarkは一番上にする必要があると書きましたが、もし逆にした場合は以下のようになってしまいます。これでもちゃんとリンクは当たっているので大丈夫といえば大丈夫かもですが・・・。

終わりに

以上がProseMirrorでautolinkするプラグインを作成する内容でした。検索するとそれっぽいのがあってできるかと思ったら意外と期待する動きになってくれず、最終的には自作することになり大分時間をかけてしまいました・・・。ProseMirrorは拡張性が高くてとても良いのですが、すぐに使えるプラグインとかがあまりないところがちょっと辛いところですね。。
autolinkしたいケースは割とある気がするので、そういった方達の参考になれれば幸いです。

Discussion

ひとしろひとしろ

Tiptapにて、正規表現にマッチした部分にmarkしたくて困っていましたが、この記事で解決しました!
英語記事では解決策が見当たらず💧 ほんとうに助かりました。ありがとうございます😊