この記事では、Next.jsとMilkdownを使用してカスタムマークダウンエディタを開発する方法を紹介する。Tailwind CSSを使用してスタイルを適用し、Material-UIのコンポーネントでユーザーインターフェースを構築する。ちなみに、Next.jsバージョンは14.0.3
公式ドキュメント
完成図
以下のように、マークダウンをプレビューで編集でき、ツールバーでカスタムできる仕様にした。
公式ドキュメントでは、ツールバーの設定などはバラバラに解説されているため、当記事はマークダウンエディタを構築したい人にとって非常に有益な内容であることを自負している。
使用するライブラリ
以下のライブラリを使用する:
@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
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
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>
);
};
コメント