ProseMirrorを使って空白を可視化する
始めに
リッチテキストエディタのライブラリの一つにProseMirrorがあります。これはカスタマイズ性に非常に富んでおり、頑張れば普段使っているテキストエディタと同等の機能を作ることもできると思います。
テキストエディタといえば、昔TeraPadを使っていた時に空白文字を可視化できることを思い出し、それがProseMirrorで実装できるのか気になったので実際に作ってみました。
作ったもの
今回作ったものは以下です。空白の見せ方は何が良いか決めらなかったのでtofu: □
とbump: ␣
のパターンを用意しました。実装方法もDecorationを使う方法とMarkを設定する方法があったのでそれぞれ実装してみました。
gifアニメ
ProseMirrorを使って空白を可視化する
ProseMirrorで空白を可視化する方法は2つありますが、どちらも空白に対してspan
タグで括って、クラス名を付与するプラグインを作り、そのクラス名にCSSを設定します。今回は .highlight-space
というクラス名を振るのでこれがある時にCSSが当たるように設定します。
.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
でクラス名が設定されるようにします。
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), // の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);
},
},
});
};
空白については半角スペースと全角スペースを探せば良さそうですが、これだけでは何故か上手くいかず
として出力されていたスペースのcharCodeが違っていたので、対象の空白のcharCodeを調べてそれも合致するようにしています。
これで完成ですが、一つ問題がありました。日本語入力中(IME入力中)の時はクラス名の設定が入力中のテキスト全てにかかってしまいました。
色々調べてみましたが、Decoration.inlineで設定した内容の反映をする際にIME入力中があるとそれも巻き込んでしまうバグな気がしており、ライブラリが修正されないと回避できなそうな感じでした。。IMEが確定されたら正しくスタイリングされるので気にならなければこのままでも良いですが、もしこの現象を回避したい場合は次の方法で実装すると良さそうでした。
MarkTypeを設定する方法
ProseMirrorでテキストに装飾する方法は他にもあって、それはmarkをセットする方法です。該当のmarkが設定されたときに.highlight-space
クラスが当たるように設定することでDecoration.inline
のやり方と同じように可視化することができます。
const schema = new Schema({
// nodesの設定は省略
marks: {
space: {
// 直後のテキスト入力でspace Markが結合されないようにする
inclusive: false,
parseDOM: [
{
tag: 'span.highlight-space',
},
],
toDOM() {
return ['span', { class: 'highlight-space' }, 0];
},
},
},
});
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), // の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を振ることで回避することができます。
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];
},
},
},
});
/**
* 空白に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), // の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を設定することで期待する動作はできるようになりましたが、こちらも結構ハックしたような実装になったので保守しづらそうなコードになってしまいました。
特定の文字に装飾を当てたい時にもこのやり方が使えると思いますので、参考になれたら幸いです。
Discussion