[Next.js]SimpleMDE系の使わず自力で1ペインMarkdownエディタを実装するという地獄
0. 今回作ったもの
- Demo
- npm に登録したもの
1. 地獄への道、その扉を開くまで
1.1. Markdown エディタの種類
一般的な Markdown エディタには、おおよそ以下のような二種類が存在しています。
- 1 ペイン系
入力テキストそのものに装飾をかける
Zenn で使われている方式 - 2 ペイン系
テキストとレンダリング結果を左右などに分けてそれぞれ表示する
Qiita などで使われている方式
どちらが高度な技術を要するかといえば圧倒的に 1 ペイン系です。そもそも Web アプリケーションで入力中テキストをリアルタイム装飾するのは、洒落にならない労力が必要です。
1.2. ワンペイン Markdown エディタを組み込む方法
選択肢はSimpleMDEとその改良版のEasyMDE、そしてそれを React で利用する場合はReactSimpleMDEがセオリーとなっています。事実上、他に選択肢はありません。
1.3. Next.js と相性が悪い EasyMDE
EasyMDE はパッケージをインポートした時点で、window オブジェクトへアクセスしに行きます。これをやってしまうと Next.js が SSR を行う際に undefined の中身に飛び込み死んでしまいます。そして Markdown エディタ生成時の動きを見ると、特に存在している必要の無い textarea を DOM 上に置くのが必須となっいます。実際に動作する時は textarea の隣にcontentEditable=true
を含んだ DOM ツリーを作ります。もともと JavaScript の汎用ライブラリなので致し方ないのですが、完全に仮想 DOM の制御を離れた状態になり、React からは使いづらい形になっています。
1.4. Next.js と相性が悪い ReactSimpleMDE
ReactSimpleMDE は EasyMDE を React から簡単に利用するためのパッケージです。動作としては EasyMDE の機能をそのまま呼び出しており、やっぱり Next.js の SSR でご臨終です。ReactSimpleMDE のパッケージを遅延ロードさせれば使用は可能ですが、一手間かかります。その他にオプションやテキスト内容を変更しただけで EasyMDE のオブジェクトを作り直すという意味の分からない挙動をしています。Markdown エディタで EasyMDE 系 を使うにしても、ReactSimpleMDE を使うという選択は避けることをおすすめします。
1.5. いや、もう、これは…やるしかない
選択肢が限られていてどうしようもないので自分で作ることにしました。この先に続くのは茨の道というか、ただの地獄であることは想像に易いのですが、車輪の再発明を生業と謳っている以上、やるしかありません。
2. 地獄のメニュー
2.1. 表示するだけなら簡単
Markdown を React で表示するだけならreact-markdownを使えば簡単です。しかし表示するだけなので、当然入力を受け付けたりはしてくれません。
2.2. ワンペイン Markdown エディタの道のり
- contentEditable=true で装飾可能なテキスト編集エリアを作る
- Markdown テキストを入力する
- Markdown の内容解析
- テキストを装飾する
これを見てなんだ簡単だと思った人、正解です。これだけなら簡単です。地獄はこれからです。
- テキストを装飾するとキャレット(カーソル)が外れる
- キャレットを元の位置に復元
はい、面倒くさいところに入りました。テキストを装飾した時点で内容はいったん書き換わり、もはや元のデータとは別物になっています。装飾後、元の位置だと思われるノードを DOM から特定し、キャレットを復元しなければなりません。持っている情報は、装飾が付く前の DOM の構成と、キャレットが載っていたノードとその中のオフセットです。
これをやるのに DOM を取り除いた文字数を数える必要があります。レンダリングされた DOM ツリーから HTML ノードが大量に挟まっている中をかき分けて、その場所を発見しなければなりません。さらに恐ろしいのが改行です。テキストなら\n
を数えれば改行を一文字と数えられますが、DIV や P などのブロック系要素は前後関係によって改行したりしなかったりします。
しかし、これはまだ地獄の一丁目です。
2.3. とりあえず Markdown の構文解析しよう
Markdown の構文解析まで自分で作っていたらさすがに洒落にならないのでunifiedを使用します。これに Markdown の基本パーサのremark-parseとテーブルの解析にremark-gfmを入れます。HTML への変換はやらないので構成はこれだけです。最終的に自前で ReactNode を吐く形に持って行きます。
unified を通した結果、ツリー構造で Markdown の解析結果が得られます。その中には表示のための分解後のテキスト情報などが入っているのですが、使いませんというか使えません。この分解テキストは# タイトル
をタイトル
と変換してくれるので、エディタを作る都合上、消されると困るのです。使える情報は heading(タイトル系)や strong(強調)や emphasis(斜体)などが入った type と、元になったテキスト位置範囲の offset です。
2.4. 解析した内容を ReactNode に変換
unified で解析した位置情報を使って、元のテキストを損なわないように ReactNode の階層を作っていきます。段落を表す paragraph
やテキストノードであるtext
など、装飾に必要ないものはカレーにスルーします。そしてヘッダーのheading
や強調表示のstrong
の箇所にノードを作成してテキストを挟み込んでいきます。
また、テキストの手動編集でノードの構成の変更が検知できるように、ノードの数を数えて key を設定します。
import React from "react";
import type unist from "unist";
import type { Root, Content } from "mdast";
import { unified, Processor, Compiler } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
export type VNode = { type: string; value?: unknown; start: number; end: number };
function ReactCompiler(this: Processor) {
const expandNode = (node: Content & Partial<unist.Parent<Content>>, nodes: VNode[]) => {
nodes.push({
type: node.type,
start: node.position!.start.offset!,
end: node.position!.end!.offset!,
value: node.type === "heading" ? node.depth : undefined,
});
node.children?.forEach((n) => expandNode(n, nodes));
};
const reactNode = (vnodes: VNode[], value: string): React.ReactNode => {
let position = 0;
let index = 0;
let nodeCount = 0;
const getNode = (limit: number): React.ReactNode => {
const nodes = [];
while (position < limit && index < vnodes.length) {
const vnode = vnodes[index];
const [start, end] = [vnode.start, vnode.end];
if (start > limit) {
nodes.push(value.substring(position, limit));
position = limit;
break;
}
if (position < start) {
if (index < vnodes.length) {
nodes.push(value.substring(position, start));
position = start;
} else {
nodes.push(value.substring(position, end));
position = end;
}
} else {
const TagName = {
heading: "h" + vnode.value,
strong: "strong",
emphasis: "em",
inlineCode: "code",
code: "code",
list: "code",
table: "code",
}[vnode.type] as keyof JSX.IntrinsicElements;
index++;
if (TagName) {
if (index < vnodes.length) {
nodes.push(React.createElement(TagName, { key: index }, getNode(end)));
} else {
nodes.push(React.createElement(TagName, { key: index }, value.substring(start, end)));
position = end;
}
}
}
}
if (position < limit) {
nodeCount++;
nodes.push(value.substring(position, limit));
position = limit;
}
nodeCount += nodes.length;
return nodes.length ? nodes : null;
};
const nodes = getNode(value.length);
if (!nodes) return;
return React.createElement("span", { key: nodeCount }, nodes);
};
const Compiler: Compiler = (tree: unist.Node & Partial<unist.Parent<unist.Node>>, value) => {
const nodes: VNode[] = [];
expandNode(tree as Content, nodes);
return reactNode(
nodes.filter((node) => !["text", "paragraph"].includes(node.type)),
String(value)
);
};
this.Compiler = Compiler;
}
const processor = unified().use(remarkParse).use(remarkGfm).use(ReactCompiler) as Processor<
Root,
Root,
Root,
React.ReactElement
>;
export const useMarkdown = (value: string) => {
const node = React.useMemo(() => {
return processor.processSync(value).result;
}, [value]);
return node;
};
2.5. 変換した ReactNode を contentEditable のノードへレンダリング
変換した ReactNode を contentEditable を設定したタグの children に設定します。これで内容が表示されます。さて、ここからが本当の地獄です。contentEditable にすると内容が手動変更できます。テキストエディタなので当たり前ですが、React は手動変更によってノードの内容に変更が起こるとその検知は不可能なので表示内容がぶっ飛びます。そして同じ内容が多重に表示されたり、削除不能ノードが発生してエラー落ちします。dangerouslySetInnerHTML を使って回避する手はあるのですが、仮想 DOM の差分更新が生かせません。
そこで対策です。手動更新が駄目なら onKeyDown と onKeyPress で送られてくるデータを自前管理する!
どっちみち入力内容をチェックして装飾を施す必要があるので、手動入力のテキストを入力時に全部チェックしてしまえば良いのです。onPaste や onDrop も全部自前でやればいいのです。ブラウザのデフォルト機能に頼ったら負け、何もかもを疑い、何者も信じない心が必要です。そう、地獄を進めば心は荒むのです。
const insertText = (text?: string, start?: number, end?: number) => {
const pos = getPosition();
const currentText = refNode.current!.innerText;
const startPos = start !== undefined ? start : pos[0];
const endPos = end !== undefined ? end : start !== undefined ? start : pos[1];
pushText(
currentText.slice(0, startPos) + (text || "") + currentText.slice(endPos, currentText.length)
);
property.position = startPos + (text?.length || 0);
};
const deleteInsertText = (text: string, start: number, end: number) => {
const pos = getPosition();
const currentText = refNode.current!.innerText;
if (pos[0] < start) {
const currentText2 = currentText.slice(0, start) + currentText.slice(end, currentText.length);
pushText(
currentText2.slice(0, pos[0]) + text + currentText2.slice(pos[1], currentText2.length)
);
property.position = pos[0] + text.length;
} else {
const currentText2 =
currentText.slice(0, pos[0]) + text + currentText.slice(pos[1], currentText.length);
pushText(currentText2.slice(0, start) + currentText2.slice(end, currentText2.length));
property.position = pos[0] + text.length + start - end;
}
};
const deleteText = (start: number, end: number) => {
const currentText = refNode.current!.innerText;
const text = currentText.slice(0, start) + currentText.slice(end, currentText.length);
pushText(text);
};
const handleInput: FormEventHandler<HTMLElement> = (e) => {
e.preventDefault();
const currentText = e.currentTarget.innerText;
if (!property.active) {
pushText(currentText);
property.position = getPosition()[0];
}
};
const handlePaste: ClipboardEventHandler<HTMLElement> = (e) => {
const t = e.clipboardData.getData("text/plain").replace(/\r\n/g, "\n");
insertText(t);
e.preventDefault();
};
const handleDragStart: DragEventHandler<HTMLDivElement> = (e) => {
property.dragText = e.dataTransfer.getData("text/plain");
};
const handleDrop: DragEventHandler<HTMLDivElement> = (e) => {
if (document.caretRangeFromPoint) {
const p = getPosition();
var sel = getSelection()!;
const x = e.clientX;
const y = e.clientY;
const pos = document.caretRangeFromPoint(x, y)!;
sel.removeAllRanges();
sel.addRange(pos);
const t = e.dataTransfer.getData("text/plain").replace(/\r\n/g, "\n");
deleteInsertText(t, p[0], p[1]);
} else {
const p = getPosition();
const range = document.createRange();
range.setStart((e.nativeEvent as any).rangeParent, (e.nativeEvent as any).rangeOffset);
var sel = getSelection()!;
sel.removeAllRanges();
sel.addRange(range);
const t = e.dataTransfer.getData("text/plain").replace(/\r\n/g, "\n");
deleteInsertText(t, p[0], p[1]);
}
e.preventDefault();
};
const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = (e) => {
switch (e.key) {
case "Tab": {
insertText("\t");
e.preventDefault();
break;
}
case "Enter":
const p = getPosition();
if (p[0] === refNode.current!.innerText.length) {
insertText("\n\n");
property.position--;
} else insertText("\n");
e.preventDefault();
break;
case "Backspace":
{
const p = getPosition();
const start = Math.max(p[0] - 1, 0);
const end = Math.min(p[1], refNode.current!.innerText.length);
deleteText(start, end);
property.position = start;
e.preventDefault();
}
break;
case "Delete":
{
const p = getPosition();
deleteText(p[0], p[1] + 1);
property.position = p[0];
e.preventDefault();
}
break;
case "z":
if (e.ctrlKey && !e.shiftKey) {
undoText();
}
break;
case "y":
if (e.ctrlKey && !e.shiftKey) {
redoText();
}
break;
}
};
2.6. カーソル位置のノード
ブラウザの機能として辛うじて必要なのは、キャレットの位置管理です。現在どこにいるのかを計算しておかないと、テキストの変更と実際の位置が合わなくなります。このキャレットがいるのは DOM ノードの中なので、DOM をかき分け現在の位置の保存と復元します。邪魔なのは改行判定が難しいブロック要素の DOM です。
const movePosition = (editor: HTMLElement, start: number, end?: number) => {
const selection = document.getSelection();
if (!selection) return;
const findNode = (node: Node, count: number): [Node | null, number] => {
if (node.nodeType === Node.TEXT_NODE) {
count -= node.textContent!.length;
} else if (node.nodeName === 'BR') {
count -= 1;
}
if (count <= 0) {
return [node, (node.nodeType === Node.TEXT_NODE ? node.textContent!.length : 0) + count];
}
for (let i = 0; i < node.childNodes.length; i++) {
const [n, o] = findNode(node.childNodes[i], count);
if (n) return [n, o];
count = o;
}
return [null, count];
};
const [targetNode, offset] = findNode(editor, start);
const [targetNode2, offset2] = end !== undefined ? findNode(editor, end) : [null, 0];
const range = document.createRange();
try {
if (targetNode) {
range.setStart(targetNode, offset);
if (targetNode2) range.setEnd(targetNode2, offset2);
selection.removeAllRanges();
selection.addRange(range);
} else {
range.setStart(refNode.current!, 0);
selection.removeAllRanges();
selection.addRange(range);
}
} catch (e) {
console.error(e);
}
};
const getPosition = () => {
const selection = document.getSelection();
if (!selection) return [0, 0] as const;
const getPos = (end = true) => {
const [targetNode, targetOffset] = end
? [selection.anchorNode, selection.anchorOffset]
: [selection.focusNode, selection.focusOffset];
const findNode = (node: Node) => {
if (node === targetNode && (node !== refNode.current || !targetOffset)) {
return [true, targetOffset] as const;
}
let count = 0;
for (let i = 0; i < node.childNodes.length; i++) {
const [flag, length] = findNode(node.childNodes[i]);
count += length;
if (flag) return [true, count] as const;
}
count +=
node.nodeType === Node.TEXT_NODE
? node.textContent!.length
: node.nodeName === 'BR' || node.nodeName === 'DIV' || node.nodeName === 'P'
? 1
: 0;
return [false, count] as const;
};
const p = findNode(refNode.current!);
return p[0] ? p[1] : p[1] - 1;
};
そこで対策です。ブロック要素を全て無くして pre と改行コードで構成されるようにしてしまえば良い!
onKeyDown も自前管理しているので、p や div のタグ送信をせき止め、改行コードを流し込む形にするのは容易な作業です。これでキャレットの計算にブロック要素の DOM が存在しなくなりました。
outline: none;
white-space: pre-wrap;
code,
p,
div,
h1,
h2,
h3,
h4,
h5,
h6,
h7 {
display: inline;
}
2.7. うわ、なんか出来た
onDrop まわりでもう一悶着ありましたが、とにかく動くものが出来ました。なんとか地獄を脱したようです。さて、これから npm パッケージを作りましょう。
2.8. まだ終わっていなかった ESM という地獄
npm パッケージ化して node_modules へ配置する形にしたら Next.js で動かなくなりました。原因は Markdown の解析に使っているライブラリの unified が ESM 形式だからです。これを説明すると長くなりますが、一般的な Node.js のパッケージは CJS 形式なので、ESM と混合させると面倒なことになります。パッケージ化をしていない状態であれば Webpack により、よしなに対処して結合できます。しかし分離するとそうは問屋が卸しません。
対策として CJS から ESM を呼ぶように import の非同期化を行うか、今回の npm パッケージを ESM で作るという二択が考えられます。結局、後者を選びました。そこからさらに CJS と ESM に両対応している@emotion を使用していたため、逆に不具合が出るという面倒な現象に遭遇しました。EMS の状態で両対応パッケージを呼び出すと Next.js 環境下でサーバ側の処理とクライアント側の処理、それぞれで違うタイプが呼び出されます。結果として import したインスタンスの中身に default が付いていたりいなかったりのような症状が出ました。とにかくそれも対策を取りました。
import type { CreateStyled } from "@emotion/styled";
import styled from "@emotion/styled";
export const Root = (typeof styled === "function"
? styled
: (styled as { default: CreateStyled }).default)("div")``;
3. 地獄からの生還
一週間ほどの旅路になりましたが、なんとか生きて戻ってくることが出来ました。これからドキュメントを書いたり、付加機能を作ったりしなければなりません。
とりあえず出来たものは冒頭のリンクにある通り、npm に登録済みです。
再発明した車輪で走り出す!
Discussion