ProseMirrorでautolinkするプラグインを作成
始めに
ProseMirrorを使っている際に、https://~
みたいなテキストを自動でリンクっぽい見た目にしたくて、「ProseMirror autolink」と検索するとなんとなくそれっぽい記事が見つかります。しかし、よく検証すると自分が期待した挙動にはならず、最終的には自前でプラグインを作ることになりましたので、その辺についてまとめました。
今回作ったもの
今回作ったものを先に貼ります。この挙動が良くて詳細の実装が気になった方は是非続きをご覧ください。
余談: InputRuleの使用を断念した理由
検索した記事にはInputRule
というプラグインを使ってやるみたいな内容が書かれていました。正規表現を指定して、そのテキストがきた時に何かしら処理を加えるというプラグインでとても良さそうですが、致命的な問題がありました。それは直前に入力したテキストを取得できないという問題です。
handleTextInputの中でrun
というメソッドを呼び出しており、この時点ではtext
が渡っているのですが、肝心のhandlerを呼ぶときにはマッチした正規表現とテキストの位置しか渡しておらず、handler内では何が入力されたかの判断ができません。handlerで新しいトランザクションを生成すると直前に入力したテキストが入力されない状態になり、かなり致命的な状態になります。おそらくこれは単純にテキストからテキストに変換するために作られたプラグインなんでしょうね。。また、そもそもこれはキー入力時のみであり、コピペなどには対応されていないため、その辺も要件に合いませんでした。
実装方法
Prosemirrorにはmarkというものでテキストに装飾ができるため、autolink
というmarkを用意し、該当するテキストにこのmarkを付与してリンクを表現します。marksを定義する際に、上から順番にラップされている形になり、下にいると他の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に対して適応したいと思います。動作確認用として初期値は以下のようなものにします。
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を付与するトランザクションを作る
まず最初に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を付与します。
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
に登録します。
/**
* 探索範囲周辺のドキュメントでテキストの範囲を取得する
* @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;
};
リンク対象じゃないテキストからautolink markを除外する
次は除外の方を実装します。汎用性と次セクションで説明する入力中の対応も考慮してautolinkMarkType
とtargetRanges
は引数で渡すようにします。getMarkRanges
の詳細な実装はこの後説明しますが、引数に渡したmarkが適応されている範囲を取得するものになります。今回の初期値の例だと['間違えたautolink']
が取得できるようなSelectionRange
が返ってきます。あとはこのテキストが期待するURLにマッチするテキストか確認して、そうでない場合はtr.removeMark
を実行して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
にまとめます。
/**
* 探索範囲周辺のドキュメントで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
というメソッドでラップします。この中でappendTransactionForApplyAutolink
とappendTransactionForRemoveMissMatchedAutolink
を呼ぶメソッドを用意します。
/**
* 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ブロック(基本的には行ブロック)の先頭から終端までに変更します。これで検索漏れを防ぐことができます。
/**
* 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;
}
});
};
combineTransactionSteps
とgetChangedRanges
は多少改変はしてますが、基本はこちらのコードを参考に実装しました。
あとはこのプラグインを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を下にした場合
autolinkのmarkは一番上にする必要があると書きましたが、もし逆にした場合は以下のようになってしまいます。これでもちゃんとリンクは当たっているので大丈夫といえば大丈夫かもですが・・・。
終わりに
以上がProseMirrorでautolinkするプラグインを作成する内容でした。検索するとそれっぽいのがあってできるかと思ったら意外と期待する動きになってくれず、最終的には自作することになり大分時間をかけてしまいました・・・。ProseMirrorは拡張性が高くてとても良いのですが、すぐに使えるプラグインとかがあまりないところがちょっと辛いところですね。。
autolinkしたいケースは割とある気がするので、そういった方達の参考になれれば幸いです。
Discussion
Tiptapにて、正規表現にマッチした部分にmarkしたくて困っていましたが、この記事で解決しました!
英語記事では解決策が見当たらず💧 ほんとうに助かりました。ありがとうございます😊