LoginSignup
14
10

More than 1 year has passed since last update.

Reactで仮想DOM対応のMarkdownEditorを一から実装する

Posted at

Markdown エディタの種類

一般的な Markdown エディタには、おおよそ以下のような二種類が存在しています。

  • 1 ペイン系(装飾付き)
    入力テキストそのものに装飾をかける Zenn で使われている方式
  • 2 ペイン系(編集とプレビュー分離)
    テキストとレンダリング結果を左右などに分けてそれぞれ表示する Qiita などで使われている方式

どちらが高度な技術を要するかといえば圧倒的に 1 ペイン系です。そもそも Web アプリケーションで入力中テキストをリアルタイム装飾するのは、洒落にならない労力が必要です。
それに対して編集領域とは関係ない場所にプレビュー表示をするのは、比較的簡単に実装できます。

もちろん 1 ペイン系のエディタを 2 ペインにしてはいけないということはないので、装飾付きエディターの横に完全なプレビューを表示するということも可能です。

React の仮想 DOM で編集領域を装飾する Markdown エディタが存在しない理由

リッチテキストエディタは<div>などの要素に対して contentEditable を設定する必要があります。こうすると子ノードに対してタグなどで装飾をかけつつ、ユーザがブラウザ上でテキスト編集を行えます。

しかし仮想 DOM でレンダリングされている領域に対しユーザが行った入力や削除すると、仮想 DOM と紐付いたノードが破壊されます。この影響で DOM のノードが増えたり無くなったりすると、表示がダブる、ランタイムエラーを起こす等の症状が出ます。

contentEditable の要素に対しては dangerouslySetInnerHTML に仮想 DOM の内容を展開すればそういった問題は起こりません。その代わり仮想 DOM による差分更新のメリットは無くなります。

contentEditable を使いつつ仮想 DOM をそのまま使う方法は、入力イベントを全て自分で制御して、ブラウザが直接ノードを作成するのを防ぐことです。ただし防げない入力イベントがあります。IME による入力です。これだけはどうにもならなりません。対処するならば、IME 入力確定後に DOM から対象のテキストを削除し、React が持っているキャッシュと矛盾しない状態に戻します。

リッチ MarkdownEditor の動作

基本は以下の繰り返しです。

  • Markdown テキストの解析
  • ReactNode への変換
  • レンダリング
  • キャレット位置を復元
  • ユーザの入力でテキストを修正

contentEditable の要素に対して再レンダリングを掛けると、キャレット(カーソール)の位置が失われます。これを毎回、正確に復元しなければなりません。面倒なのはキャレット位置の取得と設定に使われるのが、対象のノードとオフセットということです。装飾を付けるためのノードの構成が変わってしまうので、ノードとオフセットという単位では元の位置に戻すことは出来ません。

手段として、ノードの種類を無視してテキストの文字数を数えるという方法になります。ここで面倒なのはブロック系タグです。前後の関係次第で改行が入ったり入らなかったりします。これをテキストと関連付けるのが非常に困難です。これを簡単にするため、ブロックスタイルをインラインに変更して改行を確実に制御します。

成果物

ということで、React 仮想 DOM 対応のマークダウンエディタを作成しました。
ちなみにNext.jsで使用すると、SSR時に編集内容の初期表示がHTML化されます。
React用マークダウンエディタとしてよく用いられているreact-simplemde-editorと違って、SSR回避の遅延読み出しを行わずともエラーにはなりません。

使い方

  • サンプルソースコード

  • Demo

Sample01 エディタの基本

image.png

基本的なエディタ表示です。

コンポーネントを置くだけでエディタとしては機能しますが、他に装飾などが必要な場合はスタイルを付加します。
MarkdownEditor<div>で構成されているので、スタイルやイベントなどは同じものが使用できます。

  • Sample01/index.module.scss
.markdown {
  border: 1px solid;
  padding: 8px;
  border-radius: 8px;
}
  • Sample01/index.tsx
import { MarkdownEditor } from "@react-libraries/markdown-editor";
import styled from "./index.module.scss";

const defaultValue = `# Title

Putting **strong** in a sentence

- ListItem
- ListItem

## Table

| Header1       | Header2                                    |
| ------------- | ------------------------------------------ |
| name1         | info1                                      |
| name2         | info 2                                     |

# A*B*CD

AAAAAAA`;

const Page = () => {
  return <MarkdownEditor className={styled.markdown} defaultValue={defaultValue} />;
};
export default Page;

Sample02 スタイルの適用

image.png

各ノードにはmdastの字句解析したタイプ名がdatatypeに格納されています。これをセレクタで指定することによって、色などの変更が可能です。

  • Sample02/index.module.scss
.markdown {
  border: 1px solid;
  padding: 8px;
  border-radius: 8px;

  [datatype="heading"] {
    color: blue;
  }
  [datatype="strong"] {
    color: red;
  }
  [datatype="emphasis"] {
    color: indigo;
  }

  [datatype="list"] {
    color: green;
  }
  [datatype="table"] {
    color: goldenrod;
  }
}
  • Sample02/index.tsx
import { MarkdownEditor } from "@react-libraries/markdown-editor";
import styled from "./index.module.scss";

const defaultValue = `# Title

Putting **strong** in a sentence

- ListItem
- ListItem

## Table

| Header1       | Header2                                    |
| ------------- | ------------------------------------------ |
| name1         | info1                                      |
| name2         | info 2                                     |

# A*B*CD

AAAAAAA`;

const Page = () => {
  return <MarkdownEditor className={styled.markdown} defaultValue={defaultValue} />;
};
export default Page;

装飾時には注意点があります。display:blockを使いたい場合は、次の<br>display:noneにする必要があります。これをやらないと改行位置がずれて、エディタが正常に動作しなくなります。

[datatype="code"] {
  display: block;
}
[datatype="code"] + br {
  display: none;
}

Sample03 カスタムコンポーネント

image.png

components パラメータで各タイプに対応したカスタムノードを作成できます。React のイベントを入れたり、表示内容のコントロールが可能です。

サンプルは、マウスが乗った場所のノードタイプを表示します。

注意点はテキスト文字数が変化すると、エディタが正常に動作しなくなることです。ノードの追加は問題ありませんが、テキスト文字数は変化しないようにしてください。また、どうしてもテキストを追加したい場合はdata-type="ignore"を設定すれば、文字数のカウントからは除外されます。contentEditable={false}にして、編集対象から除外する必要もあります。

  • Sample03/index.module.scss
.markdown {
  border: 1px solid;
  padding: 8px;
  border-radius: 8px;
}
  • Sample03/index.tsx
import { MarkdownComponents, MarkdownEditor } from "@react-libraries/markdown-editor";
import { ElementType, useState } from "react";
import styled from "./index.module.scss";

const defaultValue = `# Title

Putting **strong** in a sentence

- ListItem
- ListItem

## Table

| Header1       | Header2                                    |
| ------------- | ------------------------------------------ |
| name1         | info1                                      |
| name2         | info 2                                     |

# A*B*CD
---
AA~~AA~~AAA
`;

// const defaultComponents: MarkdownComponents = {
//   heading: ({ children, node, ...props }) => React.createElement('h' + node.depth, props, children),
//   strong: ({ children, node, ...props }) => React.createElement('strong', props, children),
//   emphasis: ({ children, node, ...props }) => React.createElement('em', props, children),
//   inlineCode: ({ children, node, ...props }) => React.createElement('em', props, children),
//   code: ({ children, node, ...props }) => React.createElement('code', props, children),
//   link: ({ children, node, ...props }) => React.createElement('span', props, children),
//   image: ({ children, node, ...props }) => React.createElement('span', props, children),
//   list: ({ children, node, ...props }) => React.createElement('span', props, children),
//   html: ({ children, node, ...props }) => React.createElement('span', props, children),
//   table: ({ children, node, ...props }) => React.createElement('code', props, children),
//   delete: ({ children, node, ...props }) => React.createElement('del', props, children),
//   paragraph: ({ children, node, ...props }) => React.createElement('p', props, children),
//   blockquote: ({ children, node, ...props }) => React.createElement('span', props, children),
// };

const Page = () => {
  const [message, setMessage] = useState("none");
  const components: MarkdownComponents = {
    strong: ({ children, node, ...props }) => (
      <strong
        {...props}
        onMouseOver={(e) => {
          setMessage("strong");
          e.stopPropagation();
        }}
      >
        {children}
      </strong>
    ),
    emphasis: ({ children, node, ...props }) => (
      <em
        {...props}
        onMouseOver={(e) => {
          setMessage("emphasis");
          e.stopPropagation();
        }}
      >
        {children}
      </em>
    ),
    table: ({ children, node, ...props }) => (
      <code
        {...props}
        onMouseOver={(e) => {
          setMessage("table");
          e.stopPropagation();
        }}
      >
        {children}
      </code>
    ),
    heading: ({ children, node, ...props }) => {
      const Tag = ("h" + node.depth) as ElementType;
      return (
        <Tag
          {...props}
          onMouseOver={(e: React.MouseEvent<HTMLHeadingElement>) => {
            setMessage("heading");
            e.stopPropagation();
          }}
        >
          {/* If you use `data-type="ignore"`, it will be excluded from the character count. */}
          <div style={{ display: "block" }} contentEditable={false} data-type="ignore">
            [Header]
          </div>
          {children}
        </Tag>
      );
    },
  };

  return (
    <>
      <div>Message: {message}</div>
      <MarkdownEditor
        className={styled.markdown}
        defaultValue={defaultValue}
        components={components}
      />
    </>
  );
};
export default Page;

Sample04 外部コントロール

image.png

dispatchMarkdown によって、マークダウンエディタを外側からコントロールが可能です。利用する際はuseMarkdownEditoreventオブジェクトを作成し、それを<MarkdownEditor>に渡します。

  • Sample04/index.module.scss
.root {
  padding: 8px;
  .markdown {
    border: 1px solid;
    padding: 8px;
    border-radius: 8px;
  }
  button {
    margin: 8px;
    padding: 8px;
    border-radius: 8px;
  }
}
  • Sample04/index.tsx
import React, { useState } from "react";
import {
  MarkdownEditor,
  useMarkdownEditor,
  dispatchMarkdown,
} from "@react-libraries/markdown-editor";
import styled from "./index.module.scss";
const defaultValue = `# Title

Putting **strong** in a sentence

- ListItem
- ListItem

## Table

| Header1       | Header2                                    |
| ------------- | ------------------------------------------ |
| name1         | info1                                      |
| name2         | info 2                                     |

# A*B*CD

AAAAAAA
`;
const Page = () => {
  const [value, setValue] = useState(defaultValue);
  const [message, setMessage] = useState("");
  const event = useMarkdownEditor();

  return (
    <div className={styled.root}>
      <button
        onClick={() => {
          setValue(defaultValue);
        }}
      >
        init
      </button>
      <button
        onClick={() => {
          dispatchMarkdown(event, {
            type: "getPosition",
            payload: {
              onResult: (start, end) => setMessage(`start:${start},end:${end}`),
            },
          });
        }}
      >
        getPosition
      </button>
      <button
        onClick={() => {
          dispatchMarkdown(event, {
            type: "setPosition",
            payload: { start: 0, end: -1 },
          });
        }}
      >
        setPosition
      </button>
      <button
        onClick={() => {
          dispatchMarkdown(event, {
            type: "setFocus",
          });
        }}
      >
        setFocus
      </button>
      <button
        onClick={() => {
          dispatchMarkdown(event, {
            type: "update",
            payload: { value: "{new value}\n", start: 0 },
          });
        }}
      >
        insert to first
      </button>
      <button
        onClick={() => {
          dispatchMarkdown(event, {
            type: "update",
            payload: { value: "{new value}\n" },
          });
        }}
      >
        insert to caret
      </button>
      <button
        onClick={() => {
          dispatchMarkdown(event, {
            type: "undo",
          });
        }}
      >
        undo
      </button>
      <button
        onClick={() => {
          dispatchMarkdown(event, {
            type: "redo",
          });
        }}
      >
        redo
      </button>
      <div>{message}</div>
      <MarkdownEditor className={styled.markdown} event={event} value={value} onUpdate={setValue} />
    </div>
  );
};
export default Page;

Sample05 2 ペイン表示とスクロール

Mozilla Firefox 2021-10-18 20-54-46.gif

左をマークダウンエディタ、右をreact-markdownを利用した完全なプレビュー画面にしています。左のスクロール位置に合わせて、右のプレビュー位置を変更するようにしています。

getScrollLineでエディタのスクロール上部の行数を取り出し、react-markdown内に入っている行数と照らし合わせて最適な位置を設定します。

  • Sample05/index.module.scss
.root {
  padding: 8px;
  display: flex;
  height: 300px;
  overflow: hidden;
  > * {
    flex: 1;
    border: 1px solid;
    padding: 8px;
    border-radius: 8px;
    overflow: auto;
    max-height: 100%;
    margin: 16px;
  }
}
  • Sample05/index.tsx
import React, { useRef, useState } from "react";
import {
  MarkdownEditor,
  useMarkdownEditor,
  dispatchMarkdown,
} from "@react-libraries/markdown-editor";
import styled from "./index.module.scss";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";

const defaultValue = `# Title

Putting **strong** in a sentence  

- ListItem
- ListItem

## Table

| Header1       | Header2                                    |
| ------------- | ------------------------------------------ |
| name1         | info1                                      |
| name2         | info 2                                     |

# A*B*CD

AAAAAAA  
`;
const Page = () => {
  const [value, setValue] = useState(defaultValue);
  const event = useMarkdownEditor();
  const ref = useRef<HTMLDivElement>(null);
  const handleScroll = () => {
    dispatchMarkdown(event, {
      type: "getScrollLine",
      payload: {
        onResult: (line) => {
          const nodes = ref.current!.querySelectorAll<HTMLElement>("[data-sourcepos]");
          const near = Array.from(nodes).reduce<[number, HTMLElement] | undefined>((near, node) => {
            const v = node.dataset.sourcepos!.match(/(\d+):\d+-(\d+):\d+/);
            if (!v) return near;
            const [start, end] = [Number(v[1]), Number(v[2])];
            return line >= start && line <= end && (near === undefined || start - line < near[0])
              ? [start - line, node]
              : near;
          }, undefined);
          if (near) {
            near[1].scrollIntoView();
          }
        },
      },
    });
  };

  return (
    <div className={styled.root}>
      <MarkdownEditor
        className={styled.markdown}
        event={event}
        value={value}
        onUpdate={setValue}
        onScroll={handleScroll}
      />
      <div ref={ref}>
        <ReactMarkdown remarkPlugins={[remarkGfm]} sourcePos={true}>
          {value}
        </ReactMarkdown>
      </div>
    </div>
  );
};
export default Page;

まとめ

作る前、Web ブラウザでリッチテキスト系の機能を自前で実装するのは、どう考えても地獄の作業になると思っていました。作ってみた結果、やはりその通りでした。対処しなければならないことが多いのです。また、一般的なリッチテキストエディタの多くで用いられている、廃止予定のDocument.execCommandは一切使っていません。全て自分で制御するようにしました。

ここまで作るのに、平日の空いた時間や休日を利用して二週間程度かかりました。

「やっていたら出来上がった」というのが感想です。その過程では車輪の再発明で積み重ねた知識が重要でした。今後も車輪を作り続けていきます。

14
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
10