🖊️

Zenn CLI でリッチエディタを使いたい!

に公開

Zenn で記事を執筆する際はどのエディタを使っていますか?
Web エディタか Zenn CLI が多いと思います。

僕は今まで Web エディタが好きで利用していたのですが、以下の観点が気になり始めました。

  • マークダウンと表示の切り替えにタイムラグがあり、スクロール位置も合わない

  • 文章が長くなると編集したい箇所を探すのが大変になってくる

  • マークダウンと表示の対応関係がわかりずらい

Zenn CLI だとある程度改善されますが、根本的に解決できない箇所もあります。

そこで Notion ライクに執筆したいこともあり、 Zenn CLI に機能拡張という形で WYSIWYG エディタを開発しました。

本記事ではその機能と関連技術について解説します!

開発したもの

WYSIWYGエディタの動作

成果物のまま編集可能な WYSWIYG エディタで、Zenn の記事を執筆できます!

このエディタは、Zenn CLI というローカルでマークダウンファイルの作成やプレビューができるツールの拡張という形で開発しています。
なので、Zenn CLI の利用感を損なわずに WYSIWYG エディタを活用することが可能です。

エディタのお試し用に Web 版もありますが、マークダウン貼り付けや Git 管理が出来ないなど、実用面で少し問題があるため Zenn CLI 版をおすすめします。

https://zenn.dev/zenn/articles/zenn-cli-guide

始め方

Zenn CLI と同じ方法で始められます。
異なる点は、zenn-cli-wysiwyg パッケージをインストールすることです。

zenn-cli-wysiwygの始め方
# 適当な空ディレクトリに移動する
npm init -y
npm install zenn-cli-wysiwyg
npx zenn init
npx zenn

(皆さんのスターがモチベーションになるのでぜひ!!)

https://github.com/karintou8710/zenn-editor-wysiwyg

機能紹介

Zenn CLI 版は編集モードが追加されており、記事画面のスイッチで切り替えができます。

エディタでは、数式以外の Zenn 記法に対応しています。

以下では、いくつか機能をピックアップして紹介します。

リアルタイムでマークダウンファイルと同期

リアルタイム編集

目玉機能です!
編集モードでの更新はリアルタイムでマークダウンに変換され、ファイルと同期されます。

もちろん、逆のマークダウン → エディタ も対応しています。

埋め込み要素の URL 貼り付け

埋め込み貼り付け

マークダウンでは @[...](...) の記法が必要なものでも、URL 貼り付けだけで追加するできます。(マークダウン出力では Zenn 記法に変換されます)

特に SpeakerDeck は iframe から ID を取り出す作業が手間でしたが、本エディタではスライドの URL を貼り付けるだけで、自動的に ID を抽出してくれます。

画像ファイルのドロップ・ペースト

画像ファイルドロップ

Zenn CLI では画像のアップロードが面倒なことの 1 つでしたが、WYSIWYG エディタではそれを解決しています。

ドロップ・ペーストされた画像ファイルは images/<slug>/<uuid>.<ext> に保存されます。

スラッシュコマンド

スラッシュコマンド

Notion のようにスラッシュコマンドにも対応しています。
マークダウン記法を知らなくても、各種ノードを作成することが可能です。


詳細の機能は以下の記事をご確認ください!

https://zenn.dev/karintou/articles/eabe0354fcc947

技術

zenn-editor をフォークして開発をしています。主な変更内容は以下です。

  • zenn-cli に Web 編集モードを追加

  • WYISWYG エディタ本体の開発

  • zenn-markdown-html をブラウザで実行可能に

zenn-editor のプロジェクト構成

zenn-editor はモノレポで、以下のパッケージで構成されています。

パッケージ名 説明
zenn-cli ローカルの記事・本を表示するための CLI
zenn-content-css Markdown のプレビュー時のスタイル
zenn-embed-elements ブラウザ上で動作してほしい埋め込み要素( Web Components で実装)
zenn-markdown-html Markdown を HTML に変換する
zenn-model 記事や本のデータを扱う

嬉しいことに、Zenn のコンテンツのスタイルを決定する zenn-content-css が提供されているため、エディタの開発を進めやすかったです。

更に、サーバーのレンダリングが必要な複雑な埋め込み要素は、なんと Zenn が無料でサーバーを用意してくれています。(商用利用は不可)

import markdownToHtml from "zenn-markdown-html";
const html = markdownToHtml(markdown, {
  embedOrigin: "https://embed.zenn.studio",
});

上のような感じで zenn-markdon-html に embedOrigin を指定すると、リンクカードや GitHub のコードなど、凝った埋め込み要素を簡単に利用可能です。

zenn-cli に Web 編集モードを追加

zenn-cli は、フロントエンドとバックエンドの構成になっています。
元々はマークダウンを更新すると、リアルタイムでフロントエンドにも反映されてプレビューが簡単になる嬉しさがありました。

今回は WYSIWYG エディタと連携するにあたって、逆方向の通信を追加しています。
具体的には WYSIWYG エディタで編集をすると、リアルタイムでマークダウンに変換されてファイルに保存されるようにしました。

この方法では、マークダウン → プレビュー で活用されていた WebSocket を採用しています。

WYISWYG エディタ

https://github.com/karintou8710/zenn-editor-wysiwyg/tree/main/packages/zenn-wysiwyg-editor

責務を分割するため、1つのパッケージとして開発しました。
リッチテキストエディター(RTE)を柔軟に構築できる TIptap を採用しています。

Tiptap は流行りのヘッドレスなため、UI のカスタマイズ性が非常に高いです。今回のように、Zenn がコンテンツの CSS を提供してくれている場合にはうってつけです。

また Tiptap はドキュメントが豊富でコードも読みやすいため、RTE の中では参考にできるものが多いと思いました。ラップ元の ProseMirror の関連コードを読んで解決できるという安心感があります。
ProseMirror の方で Discussion が活発に動いているため、こちらを参考にすることも多かったです。

Tiptap の基本

Tiptap は定義されたコンテンツしか認識しません。

内部表現であるノードを定義して、HTML → ノード を parseHTML, ノード → HTML を renderHTML で相互に変換しています。

例えば、最も基本的な段落は以下のように定義可能です。

import { mergeAttributes, Node } from "@tiptap/core";

export const Paragraph = Node.create({
  name: "paragraph",
  group: "block",
  content: "inline*",

  parseHTML() {
    return [{ tag: "p" }];
  },

  renderHTML() {
    return ["p", 0];
  },
});

段落はブロック要素であり、テキストなどのインラインノードを複数持ちます。

parseHTML では p タグを段落ノードに変換することを指示しており、renderHTML は段落ノードを p タグ + 中身のコンテンツをレンダリングしています。
(0 はノードの子要素の renderHTML を呼ぶ)

ここに、必要に応じてキーボードショートカットや入力ルールなどを追加していきます。

独自ノードの作り方

基本を確認したところで、Zenn の様々なノードをどのように定義するか見てみましょう。

方針としては、zenn-markdown-html が出力する HTML を参考に、コンテンツの種類・parseHTML・renderHTML を指定します。

例えば、以下はメッセージノードの HTML です。

<aside class="msg message">
  <span class="msg-symbol">!</span>
  <div class="msg-content">
    <p data-line="205" class="code-line">Text</p>
    <p data-line="205" class="code-line">Text</p>
  </div>
</aside>

外側の aside タグが ラッパー になっており、msg-symbol は装飾、msg-content は複数のブロック要素を含みます。基本的に、タグとノードは1:1になります。

msg-symbol は装飾向けのノードのため、別途プラグインでデコレーションとして追加します。
デコレーションとは、ProseMirorr の概念で文書内に編集不可な装飾要素を加えるために使われます。

なぜデコレーションにするのか?

通常のノードやノードビューにすると、キャレットの移動が出来なくなったり、削除可能になったりと色々バグが起きるため、編集可能文書内の装飾は デコレーションにする必要があります。

エラーが表示されるわけではないので、開発中は気づくまで辛い気持ちに。。。

この、ラッパー・装飾・コンテンツをモデル定義に反映すると、以下のようになります。

message.ts
export const Message = Node.create({
  name: 'message',
  group: 'block',
  content: 'messageContent',

  addAttributes() {
    return {
      type: {
        default: 'message',
        rendered: false,
      },
    };
  },

  parseHTML() {
    return [
      {
        tag: 'aside.msg',
        getAttrs: (element) => {
          return {
            type: element.classList.contains('alert') ? 'alert' : 'message',
          };
        },
      },
    ];
  },

  renderHTML({ node, HTMLAttributes }) {
    return [
      'aside',
      mergeAttributes(HTMLAttributes, {
        class: cn('msg', {
          alert: node.attrs.type === 'alert',
        }),
      }),
      0,
    ];
  },
...
});

message-content.ts
import { mergeAttributes, Node } from '@tiptap/react';

export const MessageContent = Node.create({
  name: 'messageContent',
  content: 'block+',

  parseHTML() {
    return [
      {
        tag: 'div.msg-content',
      },
    ];
  },

  renderHTML({ HTMLAttributes }) {
    return [
      'div',
      mergeAttributes(HTMLAttributes, {
        class: 'msg-content',
      }),
      0,
    ];
  },
...
});

デコレーションのコード (ProseMirror Plugin として実装)
decoration.ts
import type { Node } from '@tiptap/pm/model';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';

export function createMessageSymbolDecorationPlugin(nodeName: string) {
  function getDecorations(doc: Node): DecorationSet {
    const decorations: Decoration[] = [];

    doc.descendants((node: Node, pos: number) => {
      if (node.type.name === nodeName) {
        decorations.push(
          Decoration.widget(pos + 1, () => {
            const element = document.createElement('span');
            element.className = 'msg-symbol';
            element.textContent = '!';
            return element;
          })
        );
      }
    });

    return DecorationSet.create(doc, decorations);
  }

  return new Plugin({
    key: new PluginKey('messageSymbolDecoration'),
    state: {
      init(_, { doc }) {
        return getDecorations(doc);
      },
      apply(tr, oldDecorations) {
        if (!tr.docChanged) {
          return oldDecorations.map(tr.mapping, tr.doc);
        }

        return getDecorations(tr.doc);
      },
    },
    props: {
      decorations(state) {
        return this.getState(state);
      },
    },
  });
}

ノードは タグ + クラス名で判定しています。レンダリングしたものが、再度 parseHTML できるように 同じように renderHTML も定義しています。

またメッセージには info と alert の2種類用意されているため、クラス名によってノード内の状態を切り返しています。

ここに Backspace などの特殊キーを入力した時の挙動や、マークダウン記法などを機能拡張していくことでノードを構築します。

マークダウンとの相互変換

本サービスの文書には3種類のデータ形式があります。

  • マークダウン

  • 表示用 HTML (zenn-markdown-html でレンダリングしたもの)

  • 編集用 HTML (装飾を消して Tiptap で読み込み可能にしたもの)

    • parseHTML と renderHTML は編集用 HTML を扱う

マークダウン → 編集用 HTML は、一度 zenn-markdown-html で表示用 HTML にしてから、装飾を DOM 操作で削除して、編集用 HTML に変換しています。
ここで装飾を消さないと、装飾部分がテキストとして認識されて読み込みがバグります。

編集用 HTML → マークダウンは、prosemirror-markdown で変換しています。内部的には、ノードツリーを再起的に辿って、マークダウンを出力しています。

自動テスト

PR Times さんのテスト戦略を参考に、Vitest + Vitest Browser Mode で自動テストを書いています。

https://developers.prtimes.jp/2025/02/20/press-release-editor-frontend-testing-tips/

本プロジェクトでは、キー入力やペーストなどのユーザー操作が伴うものは Vitest Browser Mode、それ以外は Vitest です。

特徴的なこととして、キー入力のテストでは選択の初期位置を setTextSelection() などで決めたいことがあります。
しかし、これは内部的に非同期なため以下のコードは失敗します。

editor.chain().setTextSelection(2).run();
await userEvent.keyboard("a"); // setTextSelection が反映されていない

そこで、選択範囲が現在の位置から変わるまでポーリングする waitSelectionChange という関数を自作し、以下のようにして解決しています。

// 現在の選択位置から変化するまで待機する
await waitSelectionChange(() => {
  editor.chain().setTextSelection(2).run();
});

await userEvent.keyboard("a");

まとめ

誇張抜きでめっちゃ使いやすいので、おすすめです!
個人開発は自身が使うものを作るという信念でしたが、これから Zenn の記事はこの WYSIWYG エディタで書く気持ちになる品物を作れたと思います。
(もちろん、この記事は zenn-cli-wysiwyg で書いてます)

今は zenn-editor をフォークして開発する形ですが、最終的には本家の zenn-editor にマージをもらえるように、完成度を高めていきます!

実際に皆さんに使っていただいて感想をいただけるとモチベーションになるので、ぜひ!

Discussion