Zenn で記事を執筆する際はどのエディタを使っていますか?
Web エディタか Zenn CLI が多いと思います。
僕は今まで Web エディタが好きで利用していたのですが、以下の観点が気になり始めました。
-
マークダウンと表示の切り替えにタイムラグがあり、スクロール位置も合わない
-
文章が長くなると編集したい箇所を探すのが大変になってくる
-
マークダウンと表示の対応関係がわかりずらい
Zenn CLI だとある程度改善されますが、根本的に解決できない箇所もあります。
そこで Notion ライクに執筆したいこともあり、 Zenn CLI に機能拡張という形で WYSIWYG エディタを開発しました。
本記事ではその機能と関連技術について解説します!
開発したもの
成果物のまま編集可能な WYSWIYG エディタで、Zenn の記事を執筆できます!
このエディタは、Zenn CLI というローカルでマークダウンファイルの作成やプレビューができるツールの拡張という形で開発しています。
なので、Zenn CLI の利用感を損なわずに WYSIWYG エディタを活用することが可能です。
エディタのお試し用に Web 版もありますが、マークダウン貼り付けや Git 管理が出来ないなど、実用面で少し問題があるため Zenn CLI 版をおすすめします。
始め方
Zenn CLI と同じ方法で始められます。
異なる点は、zenn-cli-wysiwyg
パッケージをインストールすることです。
zenn-cli-wysiwygの始め方
npm init -y
npm install zenn-cli-wysiwyg
npx zenn init
npx zenn
(皆さんのスターがモチベーションになるのでぜひ!!)
機能紹介
Zenn CLI 版は編集モードが追加されており、記事画面のスイッチで切り替えができます。
エディタでは、数式以外の Zenn 記法に対応しています。
以下では、いくつか機能をピックアップして紹介します。
リアルタイムでマークダウンファイルと同期
目玉機能です!
編集モードでの更新はリアルタイムでマークダウンに変換され、ファイルと同期されます。
もちろん、逆のマークダウン → エディタ も対応しています。
埋め込み要素の URL 貼り付け
マークダウンでは @[...](...)
の記法が必要なものでも、URL 貼り付けだけで追加するできます。(マークダウン出力では Zenn 記法に変換されます)
特に SpeakerDeck は iframe から ID を取り出す作業が手間でしたが、本エディタではスライドの URL を貼り付けるだけで、自動的に ID を抽出してくれます。
画像ファイルのドロップ・ペースト
Zenn CLI では画像のアップロードが面倒なことの 1 つでしたが、WYSIWYG エディタではそれを解決しています。
ドロップ・ペーストされた画像ファイルは images/
に保存されます。
スラッシュコマンド
Notion のようにスラッシュコマンドにも対応しています。
マークダウン記法を知らなくても、各種ノードを作成することが可能です。
詳細の機能は以下の記事をご確認ください!
技術
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 エディタ
責務を分割するため、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">Textp>
p data-line="205" class="code-line">Textp>
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 にしてから、装飾を DOM 操作で削除して、編集用 HTML に変換しています。
ここで装飾を消さないと、装飾部分がテキストとして認識されて読み込みがバグります。
編集用 HTML → マークダウン
は、prosemirror-markdown で変換しています。内部的には、ノードツリーを再起的に辿って、マークダウンを出力しています。
自動テスト
PR Times さんのテスト戦略を参考に、Vitest + Vitest Browser Mode で自動テストを書いています。
本プロジェクトでは、キー入力やペーストなどのユーザー操作が伴うものは Vitest Browser Mode、それ以外は Vitest です。
特徴的なこととして、キー入力のテストでは選択の初期位置を setTextSelection()
などで決めたいことがあります。
しかし、これは内部的に非同期なため以下のコードは失敗します。
editor.chain().setTextSelection(2).run();
await userEvent.keyboard("a");
そこで、選択範囲が現在の位置から変わるまでポーリングする waitSelectionChange
という関数を自作し、以下のようにして解決しています。
await waitSelectionChange(() => {
editor.chain().setTextSelection(2).run();
});
await userEvent.keyboard("a");
まとめ
誇張抜きでめっちゃ使いやすいので、おすすめです!
個人開発は自身が使うものを作るという信念でしたが、これから Zenn の記事はこの WYSIWYG エディタで書く気持ちになる品物を作れたと思います。
(もちろん、この記事は zenn-cli-wysiwyg で書いてます)
今は zenn-editor をフォークして開発する形ですが、最終的には本家の zenn-editor にマージをもらえるように、完成度を高めていきます!
実際に皆さんに使っていただいて感想をいただけるとモチベーションになるので、ぜひ!
Views: 0