この記事では、Next.jsとMilkdownを使用してカスタムマークダウンエディタを開発する方法を紹介する。Tailwind CSSを使用してスタイルを適用し、Material-UIのコンポーネントでユーザーインターフェースを構築する。ちなみに、Next.jsバージョンは14.0.3

公式ドキュメント

https://milkdown.dev/

完成図

以下のように、マークダウンをプレビューで編集でき、ツールバーでカスタムできる仕様にした。
公式ドキュメントでは、ツールバーの設定などはバラバラに解説されているため、当記事はマークダウンエディタを構築したい人にとって非常に有益な内容であることを自負している。

使用するライブラリ

以下のライブラリを使用する:

  • @milkdown/core: Milkdownのコアライブラリ
  • @milkdown/ctx: Milkdownのコンテキスト管理
  • @milkdown/plugin-prism: Prismを使ったシンタックスハイライト
  • @milkdown/plugin-upload: 画像アップロードプラグイン
  • @milkdown/preset-commonmark: CommonMark対応のプリセット
  • @milkdown/preset-gfm: GitHub Flavored Markdown対応のプリセット
  • @milkdown/prose: ProseMirrorベースのエディタ
  • @milkdown/react: React用Milkdownラッパー
  • @milkdown/theme-nord: Nordテーマ
  • @milkdown/transformer: Milkdown用のコンテンツトランスフォーマー
  • @tailwindcss/typography: Tailwind CSSのタイポグラフィプラグイン
  • prism-themes: Prism用のテーマ
  • tailwindcss: Tailwind CSSフレームワーク
  • @mui/material: Material-UIコンポーネント

実装

パッケージインストール

以下のコマンドで必要なパッケージをインストールする。

npm install @milkdown/core @milkdown/ctx @milkdown/plugin-prism @milkdown/plugin-upload @milkdown/preset-commonmark @milkdown/preset-gfm @milkdown/prose @milkdown/react @milkdown/theme-nord @milkdown/transformer @tailwindcss/typography prism-themes refractor tailwindcss @mui/material react react-dom

作成するファイル

実装では、以下のファイルが必要になる。

  • postcss.config.js
  • tailwind.config.js
  • src/assets/css/style.css
  • src/pages/_app.tsx
  • src/pages/index.tsx
  • src/components/common/MilkdownEditor.tsx

postcss.config.js

Tailwind CSSをPostCSSで使用するための設定ファイル。

module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};

tailwind.config.js

Tailwind CSSのカスタマイズ設定ファイル。
コンテンツのパスやプラグインを定義する。

module.exports = {
  content: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"],
  darkMode: "class",
  theme: {
    extend: {},
  },
  plugins: [require("@tailwindcss/typography")],
};

src/assets/css/style.css

Tailwind CSSのスタイルを適用し、カスタムスタイルを定義するファイル。
エディタやコンテナのスタイルもここに含まれる。

@tailwind base;
@tailwind components;
@tailwind utilities;

#__next {
  overflow-y: auto;
  word-break: break-all;
  box-sizing: border-box;
}

.milkdown-theme-nord code {
  background: rgba(135, 131, 120, 0.15);
  color: #eb575e !important;
  border-radius: 4px;
  font-size: 85%;
  padding: 0.2em 0.4em;
}

.milkdown {
  @apply bg-slate-50 px-2 py-4 border rounded;
}

.editor {
  @apply mx-auto;
}

.editor-container {
  @apply w-full max-w-2xl mx-auto;
}

.editor-toolbar {
  @apply flex gap-2 mb-2;
}

.editor-toolbar button {
  @apply p-2 bg-gray-700 text-gray-100 rounded cursor-pointer;
}

.editor-toolbar button:hover {
  @apply bg-blue-600;
}

src/pages/_app.tsx

Next.jsアプリケーションのエントリーポイント。
全体のCSSをインポートし、コンテキストプロバイダーをラップする。

"use client";

import type { AppProps } from "next/app";
import "@/assets/css/style.css";

export default function App({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />;
}

src/pages/index.tsx

Milkdownエディタのコンポーネントを表示するためのページコンポーネント。

import { MilkdownEditorWrapper } from "@/components/common/MilkdownEditor";

export default function Index() {
  return (
    <div className="editor">
      <MilkdownEditorWrapper />
    </div>
  );
}

src/components/common/MilkdownEditor.tsx

Milkdownエディタの主要な実装ファイル。エディタの設定、ツールバー、画像アップロード、リンク挿入のロジックを含む。

import React, { useEffect, useState, useRef } from "react";
import { defaultValueCtx, Editor, rootCtx, commandsCtx } from "@milkdown/core";
import { nord } from "@milkdown/theme-nord";
import { Milkdown, MilkdownProvider, useEditor } from "@milkdown/react";
import { commonmark } from "@milkdown/preset-commonmark";
import { gfm } from "@milkdown/preset-gfm";
import { prism } from "@milkdown/plugin-prism";
import { upload, uploadConfig } from "@milkdown/plugin-upload";

import "prism-themes/themes/prism-nord.css";
import "@milkdown/theme-nord/style.css";

import {
  IconButton,
  Typography,
  Dialog,
  DialogActions,
  DialogContent,
  DialogTitle,
  TextField,
  Button,
} from "@mui/material";
import BoldIcon from "@mui/icons-material/FormatBold";
import ItalicIcon from "@mui/icons-material/FormatItalic";
import BlockquoteIcon from "@mui/icons-material/FormatQuote";
import BulletListIcon from "@mui/icons-material/FormatListBulleted";
import OrderedListIcon from "@mui/icons-material/FormatListNumbered";
import CodeBlockIcon from "@mui/icons-material/Code";
import ImageIcon from "@mui/icons-material/Image";
import HardBreakIcon from "@mui/icons-material/KeyboardReturn";
import HorizontalRuleIcon from "@mui/icons-material/HorizontalRule";
import InlineCodeIcon from "@mui/icons-material/Code";
import LinkIcon from "@mui/icons-material/Link";

const markdownContent = `# Milkdown React Commonmark

> You're scared of a world where you're needed.

This is a demo for using Milkdown with **React**.

\`\`\`javascript
console.log('Hello, world!');
\`\`\`
`;

const MilkdownEditor: React.FC = () => {
  /**
   * state
   */
  const [editorInstance, setEditorInstance] = useState<Editor | null>(null);
  const [linkDialogOpen, setLinkDialogOpen] = useState(false);
  const [linkText, setLinkText] = useState("");
  const [linkUrl, setLinkUrl] = useState("");
  const fileInputRef = useRef<HTMLInputElement | null>(null);

  /**
   * エディタインスタンスを設定
   */
  const { get } = useEditor((root) =>
    Editor.make()
      .config((ctx) => {
        ctx.set(rootCtx, root);
        ctx.set(defaultValueCtx, markdownContent);
        ctx.update(uploadConfig.key, (prev) => ({
          ...prev,
          uploader: async (files, schema) => {
            const images: any[] = [];
            for (let i = 0; i < files.length; i++) {
              const file = files.item(i);
              if (file) {
                const reader = new FileReader();
                reader.onload = () => {
                  images.push(reader.result);
                  if (i === files.length - 1) {
                    // エディタに画像を挿入
                    execCommand(
                      { key: "InsertImage" },
                      { src: reader.result as string, alt: file.name, title: file.name }
                    );
                  }
                };
                reader.readAsDataURL(file);
              }
            }
            return images;
          },
        }));
      })
      .config(nord)
      .use(commonmark)
      .use(gfm)
      .use(prism)
      .use(upload)
  );

  useEffect(() => {
    const editor = get();
    if (editor) {
      setEditorInstance(editor);
    }
  }, [get]);

  /**
   * エディタツールバーのコマンドを実行
   * @param command 実行対象のコマンド
   * @param payload 挿入するコンテンツ(画像、リンクなど)
   * @returns
   */
  const execCommand = (command: { key: string }, payload?: any) => {
    if (!editorInstance) return;
    editorInstance.action((ctx) => {
      const commandManager = ctx.get(commandsCtx);
      commandManager.call(command.key, payload);
    });
  };

  /**
   * 画像アイコンクリック時のハンドラー
   */
  const handleImageUpload = () => {
    if (fileInputRef.current) {
      fileInputRef.current.click();
    }
  };

  /**
   * 画像が選択されたらInsertImageコマンドを実行
   * @param event
   */
  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const files = event.target.files;
    if (files && files.length > 0) {
      execCommand(
        { key: "InsertImage" },
        { src: URL.createObjectURL(files[0]), alt: files[0].name, title: files[0].name }
      );
    }
  };

  /**
   * リンクテキストの挿入
   */
  const handleLinkInsert = () => {
    execCommand({ key: "ToggleLink" }, { href: linkUrl, title: linkText });
    setLinkDialogOpen(false);
    setLinkText("");
    setLinkUrl("");
  };

  return (
    <div className="editor-container">
      <div className="toolbar">
        <IconButton onClick={() => execCommand({ key: "ToggleStrong" })}>
          <BoldIcon />
        </IconButton>
        <IconButton onClick={() => execCommand({ key: "ToggleEmphasis" })}>
          <ItalicIcon />
        </IconButton>
        <IconButton onClick={() => execCommand({ key: "WrapInBlockquote" })}>
          <BlockquoteIcon />
        </IconButton>
        <IconButton onClick={() => execCommand({ key: "WrapInBulletList" })}>
          <BulletListIcon />
        </IconButton>
        <IconButton onClick={() => execCommand({ key: "WrapInOrderedList" })}>
          <OrderedListIcon />
        </IconButton>
        <IconButton onClick={() => execCommand({ key: "CreateCodeBlock" })}>
          <CodeBlockIcon />
        </IconButton>
        <IconButton onClick={handleImageUpload}>
          <ImageIcon />
        </IconButton>
        <input
          type="file"
          accept="image/*"
          ref={fileInputRef}
          style={{ display: "none" }}
          onChange={handleFileChange}
        />
        <IconButton onClick={() => execCommand({ key: "WrapInHeading" }, 1)}>
          <Typography style={{ fontSize: 12 }}>H1</Typography>
        </IconButton>
        <IconButton onClick={() => execCommand({ key: "WrapInHeading" }, 2)}>
          <Typography style={{ fontSize: 12 }}>H2</Typography>
        </IconButton>
        <IconButton onClick={() => execCommand({ key: "WrapInHeading" }, 3)}>
          <Typography style={{ fontSize: 12 }}>H3</Typography>
        </IconButton>
        <IconButton onClick={() => execCommand({ key: "InsertHardbreak" })}>
          <HardBreakIcon />
        </IconButton>
        <IconButton onClick={() => execCommand({ key: "InsertHr" })}>
          <HorizontalRuleIcon />
        </IconButton>
        <IconButton onClick={() => execCommand({ key: "ToggleInlineCode" })}>
          <InlineCodeIcon />
        </IconButton>
        <IconButton onClick={() => setLinkDialogOpen(true)}>
          <LinkIcon />
        </IconButton>
      </div>
      <Milkdown />
      <Dialog open={linkDialogOpen} onClose={() => setLinkDialogOpen(false)}>
        <DialogTitle>Insert Link</DialogTitle>
        <DialogContent>
          <TextField
            autoFocus
            margin="dense"
            label="Link Text"
            fullWidth
            value={linkText}
            onChange={(e) => setLinkText(e.target.value)}
          />
          <TextField
            margin="dense"
            label="Link URL"
            fullWidth
            value={linkUrl}
            onChange={(e) => setLinkUrl(e.target.value)}
          />
        </DialogContent>
        <DialogActions>
          <Button onClick={() => setLinkDialogOpen(false)} color="primary">
            Cancel
          </Button>
          <Button onClick={handleLinkInsert} color="primary">
            Save
          </Button>
        </DialogActions>
      </Dialog>
    </div>
  );
};

export const MilkdownEditorWrapper: React.FC = () => {
  return (
    <MilkdownProvider>
      <MilkdownEditor />
    </MilkdownProvider>
  );
};

カテゴリー: Next.js