Next.jsにリッチエディタ「Tiptap」をインストールしてエディタを表示させる方法です。
バージョン情報
| ソフトウェア | バージョン |
|---|---|
| Next.js | 16.0.3 |
| ChakraUi/react | 3.29.0 |
| ark-ui | 4.9.2 |
| tiptap/react | 3.10.7 |
プロジェクトの作成
下記のコマンドを入力してNext.jsプロジェクトを作成します。
npx create-next-app@latest --typescript
Tiptapモジュールを組み込む
下記のコマンドでTiptapモジュールを組み込みます。
npm install @tiptap/extension-text-style
ChakraUI(V3系)とark-uiのインストール
下記のコマンドでChakraUI(V3系)をインストールします
npm install @chakra-ui/react@latest @emotion/react@latest
下記のコマンドでark-uiをインストールします
npm i @ark-ui/react@4
tsconfig.jsonの修正
「👈」の部分を修正しています。
tsconfig.json
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src*"] //👈修正
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
next.config.tsの修正
next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
//export default nextConfig;//修正削除
export default {//👈のように修正
experimental: {
optimizePackageImports: ["@chakra-ui/react"],
},
};
ChakraProvider をクライアントコンポーネントに分ける
ChakraProvider を"use client"コンポーネントに分けます。
(理由)
Chakra v3 はまだApp Routerに完全対応していない場合があるので、ChakraProvider は必ずclient componentにする。
app/ChakraProviderWrapper.tsxを作成
app/ChakraProviderWrapper.tsx
"use client";
import { ChakraProvider, defaultSystem } from "@chakra-ui/react";
export function ChakraWrapper({ children }: { children: React.ReactNode }) {
return <ChakraProvider value={defaultSystem}>{children}</ChakraProvider>;
}
page.tsxの修正
page.tsx
import Image from "next/image";
import TipTap from "./tiptapComponents/tiptap";
import { Provider } from "../components/ui/provider";
import { ChakraWrapper } from "./ChakraProviderWrapper";
export default function Home() {
return (
<div>
<Provider>
<ChakraWrapper>
<TipTap />
</ChakraWrapper>
</Provider>
</div>
);
}
components/ui/color-mode.tsxの修正
「👈」の部分を修正します。
components/ui/color-mode.tsx
"use client";
import type { IconButtonProps, SpanProps } from "@chakra-ui/react";
import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react";
import { ThemeProvider, useTheme } from "next-themes";
import type { ThemeProviderProps } from "next-themes";
import * as React from "react";
import { LuMoon, LuSun } from "react-icons/lu";
export interface ColorModeProviderProps extends ThemeProviderProps {}
export function ColorModeProvider(props: ColorModeProviderProps) {
return (
<ThemeProvider
attribute="class"
disableTransitionOnChange
enableSystem={true} //👈追加
defaultTheme="light" //👈追加
{...props}
/>
);
}
export type ColorMode = "light" | "dark";
export interface UseColorModeReturn {
colorMode: ColorMode;
setColorMode: (colorMode: ColorMode) => void;
toggleColorMode: () => void;
}
export function useColorMode(): UseColorModeReturn {
const { resolvedTheme, setTheme, forcedTheme } = useTheme();
const colorMode = forcedTheme || resolvedTheme;
const toggleColorMode = () => {
setTheme(resolvedTheme === "dark" ? "light" : "dark");
};
return {
colorMode: colorMode as ColorMode,
setColorMode: setTheme,
toggleColorMode,
};
}
export function useColorModeValue<T>(light: T, dark: T) {
const { colorMode } = useColorMode();
return colorMode === "dark" ? dark : light;
}
export function ColorModeIcon() {
const { colorMode } = useColorMode();
return colorMode === "dark" ? <LuMoon /> : <LuSun />;
}
interface ColorModeButtonProps extends Omit<IconButtonProps, "aria-label"> {}
export const ColorModeButton = React.forwardRef<
HTMLButtonElement,
ColorModeButtonProps
>(function ColorModeButton(props, ref) {
const { toggleColorMode } = useColorMode();
return (
<ClientOnly fallback={<Skeleton boxSize="9" />}>
<IconButton
onClick={toggleColorMode}
variant="ghost"
aria-label="Toggle color mode"
size="sm"
ref={ref}
{...props}
css={{
_icon: {
width: "5",
height: "5",
},
}}
>
<ColorModeIcon />
</IconButton>
</ClientOnly>
);
});
export const LightMode = React.forwardRef<HTMLSpanElement, SpanProps>(
function LightMode(props, ref) {
return (
<Span
color="fg"
display="contents"
className="chakra-theme light"
colorPalette="gray"
colorScheme="light"
ref={ref}
{...props}
/>
);
},
);
export const DarkMode = React.forwardRef<HTMLSpanElement, SpanProps>(
function DarkMode(props, ref) {
return (
<Span
color="fg"
display="contents"
className="chakra-theme dark"
colorPalette="gray"
colorScheme="dark"
ref={ref}
{...props}
/>
);
},
);
layout.tsxの修正
app/layout.tsx
"use client"; // Chakra + ColorMode 用の root
import { ChakraProvider, defaultSystem } from "@chakra-ui/react";
import { ColorModeProvider } from "../components/ui/color-mode";
import { schema } from "@tiptap/pm/markdown";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="light" style={{ colorScheme: "light" }}>
<body>
<ColorModeProvider>
<ChakraProvider value={defaultSystem}>{children}</ChakraProvider>
</ColorModeProvider>
</body>
</html>
);
}
/*
"use client"
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ColorModeProvider } from "../components/ui/color-mode";//追加
import { ChakraProvider, defaultSystem } from "@chakra-ui/react";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ColorModeProvider>
<ChakraProvider value={defaultSystem}>{children}</ChakraProvider>
</ColorModeProvider>
</body>
</html>
);
}
*/
Tiptap関連ファイルの新規作成
app/tiptapComponents/tiptap.tsx
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import MenuBar from "./menu-bar";
const TipTap = () => {
const editor = useEditor({
extensions: [StarterKit],
content: "<p>Hello World!</p>",
editorProps: {
attributes: {
class: "min-h-[156px] border rounded-md bg-slate-50 py-2 px-2",
},
},
immediatelyRender: false,
});
if (!editor) return null; // 👈 これが重要!
return (
<div>
<MenuBar editor={editor} />
<EditorContent editor={editor} />
</div>
);
};
export default TipTap;
menu-bar.tsx
"use client";
//import './styles.scss'
import { Button } from "@chakra-ui/react";
import { TextStyleKit } from "@tiptap/extension-text-style";
import type { Editor } from "@tiptap/react";
import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
const extensions = [TextStyleKit, StarterKit];
function MenuBar({ editor }: { editor: Editor | null }) {
if (!editor) return null;
// Read the current editor's state, and re-render the component when it changes
const editorState = useEditorState({
editor,
selector: (ctx) => {
return {
isBold: ctx.editor.isActive("bold") ?? false,
canBold: ctx.editor.can().chain().toggleBold().run() ?? false,
isItalic: ctx.editor.isActive("italic") ?? false,
canItalic: ctx.editor.can().chain().toggleItalic().run() ?? false,
isStrike: ctx.editor.isActive("strike") ?? false,
canStrike: ctx.editor.can().chain().toggleStrike().run() ?? false,
isCode: ctx.editor.isActive("code") ?? false,
canCode: ctx.editor.can().chain().toggleCode().run() ?? false,
canClearMarks: ctx.editor.can().chain().unsetAllMarks().run() ?? false,
isParagraph: ctx.editor.isActive("paragraph") ?? false,
isHeading1: ctx.editor.isActive("heading", { level: 1 }) ?? false,
isHeading2: ctx.editor.isActive("heading", { level: 2 }) ?? false,
isHeading3: ctx.editor.isActive("heading", { level: 3 }) ?? false,
isHeading4: ctx.editor.isActive("heading", { level: 4 }) ?? false,
isHeading5: ctx.editor.isActive("heading", { level: 5 }) ?? false,
isHeading6: ctx.editor.isActive("heading", { level: 6 }) ?? false,
isBulletList: ctx.editor.isActive("bulletList") ?? false,
isOrderedList: ctx.editor.isActive("orderedList") ?? false,
isCodeBlock: ctx.editor.isActive("codeBlock") ?? false,
isBlockquote: ctx.editor.isActive("blockquote") ?? false,
canUndo: ctx.editor.can().chain().undo().run() ?? false,
canRedo: ctx.editor.can().chain().redo().run() ?? false,
};
},
});
return (
<div className="control-group">
<div className="button-group">
<Button
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editorState.canBold}
className={editorState.isBold ? "is-active" : ""}
rounded="sm"
bg="gray.300"
color="black"
mr={1}
>
Bold
</Button>
<Button
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editorState.canItalic}
className={editorState.isItalic ? "is-active" : ""}
rounded="sm"
bg="gray.300"
color="black"
mr={1}
>
Italic
</Button>
<Button
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={!editorState.canStrike}
className={editorState.isStrike ? "is-active" : ""}
rounded="sm"
bg="gray.300"
color="black"
mr={1}
>
Strike
</Button>
<Button
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={!editorState.canCode}
className={editorState.isCode ? "is-active" : ""}
rounded="sm"
bg="gray.300"
color="black"
mr={1}
>
Code
</Button>
<Button onClick={() => editor.chain().focus().unsetAllMarks().run()}>
Clear marks
</Button>
<Button onClick={() => editor.chain().focus().clearNodes().run()}>
Clear nodes
</Button>
<Button
onClick={() => editor.chain().focus().setParagraph().run()}
className={editorState.isParagraph ? "is-active" : ""}
>
Paragraph
</Button>
<Button
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
className={editorState.isHeading1 ? "is-active" : ""}
>
H1
</Button>
<Button
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
className={editorState.isHeading2 ? "is-active" : ""}
>
H2
</Button>
<Button
onClick={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
className={editorState.isHeading3 ? "is-active" : ""}
>
H3
</Button>
<Button
onClick={() =>
editor.chain().focus().toggleHeading({ level: 4 }).run()
}
className={editorState.isHeading4 ? "is-active" : ""}
>
H4
</Button>
<Button
onClick={() =>
editor.chain().focus().toggleHeading({ level: 5 }).run()
}
className={editorState.isHeading5 ? "is-active" : ""}
>
H5
</Button>
<Button
onClick={() =>
editor.chain().focus().toggleHeading({ level: 6 }).run()
}
className={editorState.isHeading6 ? "is-active" : ""}
>
H6
</Button>
<Button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editorState.isBulletList ? "is-active" : ""}
>
Bullet list
</Button>
<Button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editorState.isOrderedList ? "is-active" : ""}
>
Ordered list
</Button>
<Button
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={editorState.isCodeBlock ? "is-active" : ""}
>
Code block
</Button>
<Button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editorState.isBlockquote ? "is-active" : ""}
>
Blockquote
</Button>
<Button
onClick={() => editor.chain().focus().setHorizontalRule().run()}
>
Horizontal rule
</Button>
<Button onClick={() => editor.chain().focus().setHardBreak().run()}>
Hard break
</Button>
<Button
onClick={() => editor.chain().focus().undo().run()}
disabled={!editorState.canUndo}
>
Undo
</Button>
<Button
onClick={() => editor.chain().focus().redo().run()}
disabled={!editorState.canRedo}
>
Redo
</Button>
</div>
</div>
);
}
export default MenuBar;
/*
export default () => {
const editor = useEditor({
extensions,
content: `
<h2>
Hi there,
</h2>
<p>
this is a <em>basic</em> example of <strong>Tiptap</strong>. Sure, there are all kind of basic text styles you’d probably expect from a text editor. But wait until you see the lists:
</p>
<ul>
<li>
That’s a bullet list with one …
</li>
<li>
… or two list items.
</li>
</ul>
<p>
Isn’t that great? And all of that is editable. But wait, there’s more. Let’s try a code block:
</p>
<pre><code class="language-css">body {
display: none;
}</code></pre>
<p>
I know, I know, this is impressive. It’s only the tip of the iceberg though. Give it a try and click a little bit around. Don’t forget to check the other examples too.
</p>
<blockquote>
Wow, that’s amazing. Good work, boy! 👏
<br />
— Mom
</blockquote>
`,
})
return (
<div>
<MenuBar editor={editor} />
<EditorContent editor={editor} />
</div>
)
}
*/
サイト
日経新聞
Next.jsプロジェクト作成手順
React + TypeScript: Next入門01 チュートリアルの作例を一からつくってみる
Biomeコマンド一覧
PrettierとESLintを卒業?Biomeで始める新しいコード整形
Next.jsでTiptapのプロジェクトを作成する方法
Next.jsにChakraUIを組み込む
ChakraUIのv3による変更点
【Chakra UI v3】新しくなったChakra UIを触ってみて分かったことをまとめてみる
@ark-ui/react
TipTapエディタの設定
TipTapエディタのテンプレート
Tiptapエディタのデフォルト設定
How to Build a Rich Text Editor in Next.js Using Tiptap
リッチエディタProseMirror
Next.jsとCKEditorの統合について
ウェブページにエディタ(CKEditor)を入れて入力を豊かにする
How to Integrate CKEditor in ReactJS

