Tiptapでオリジナルエディタをつくろう!
こんにちは!テラーノベルでiOS/Android/Webとフロントエンド周りを担当している @kazutoyoです!
今回はHeadless Editor FrameworkであるTiptapのご紹介と、そちらを使ってオリジナルなエディタを作成しようと思います。
Tiptapとは
TiptapはHeadless Editor Frameworkと呼ばれています。
これまでWebでのWYSIWYGエディタとしてQuill.jsやDraft.jsなどが存在しました。
これらは文字の装飾や文章の編集に関するUIをデフォルトで提供しており、エディタに特別な機能を追加しようとしたり、UIを大きく変えるというのは少々大変でした。
TiptapのようなHeadless Editor Frameworkは、エディタで必要な機能のコア機能を提供し、必要なUIや機能は拡張機能として追加できるような仕組みになっています。
これにより、エディタを提供したい人にとって柔軟性の高いエディタライブラリとなっています。
TiptapはProseMirrorというエディタをベースにしています。
そのため、TiptapからProseMirrorのAPIを呼び出すことも可能です。
また、TipTap EditorはTypeScriptで開発されており、Vanilla JS/React/Vue/Svelteなど様々な開発環境で利用することができます。
エディタライブラリは歴史が長いものが多かったりして、意外とTSの対応がされていなかったりするので嬉しいですね。
Tiptapの拡張性
TiptapはHeadless Editorであるため、デフォルトの状態ではよくあるようなWYSIWYGエディタのような機能やUIはありません。
TiptapはExtensionとしてそのようなUIや機能を追加できるようになっています。
このExtensionを使い、TiptapをGoogle Docsなどのようなリアルタイムに共同編集ができる機能を追加したり、Notion AIのような生成AIでコンテンツを作成するような機能も提供することができます。
エディタをつくってみよう!
まずはプロジェクトを作成します。
プロジェクトのセットアップ
今回はVite + TypeScript + KumaUIで作成します。
❯ pnpm create vite my-tiptap-editor
✔ Select a framework: › React
✔ Select a variant: › TypeScript + SWC
Kuma UIの追加
コンポーネントのスタイリングのため、Kuma UIを利用します。
pnpm install @kuma-ui/core
pnpm install -D @kuma-ui/vite
vite.config.ts
にKumaUIのPluginを追加します。
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import KumaUI from "@kuma-ui/vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), KumaUI()],
});
Tiptapなどの追加
Tiptapや基本的な機能を追加できる @tiptap/starter-kit
なども追加します。
pnpm add @tiptap/core @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-placeholder @tiptap/extension-underline @tiptap/extension-text-align
エディタを実装する
まず最初に @tiptap/starter-kit
を使って基本的なエディタを作ってみましょう。
Editor.tsx
コンポーネントを作成し、その中でエディタ機能をつくっていきます。
次のように useEditor
でEditorの設定などを行います。
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Box, css } from "@kuma-ui/core";
export function Editor() {
const editor = useEditor({
extensions: [StarterKit],
});
return (
<Box
border="1px solid #e5e7eb"
borderRadius="12px"
p="10"
height="100%"
width="100%"
boxShadow={["none", "0 2px 4px #4385bb12"]}
>
<EditorContent editor={editor} className={editorContentStyle} />
</Box>
);
}
const editorContentStyle = css`
width: 100%;
height: 100%;
padding: 2rem 1.3rem;
[contenteditable] {
outline: 0px solid transparent;
}
.ProseMirror {
height: calc(100% - 20px);
padding: 10px;
line-height: 1.5;
}
.tiptap ul,
.tiptap ol {
padding: 0 1rem;
}
.tiptap p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.tiptap p.is-empty::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
h1 {
font-size: 3rem;
}
h2 {
font-size: 2rem;
}
h3 {
font-size: 1.5rem;
}
`;
こちらを App.tsx
に追加しておきます。
import { Box, Heading, Spacer } from "@kuma-ui/core";
import { defaultBreakpoints } from "./theme/breakpoints";
import { Editor } from "./Editor";
function App() {
return (
<Box as="main" maxWidth={defaultBreakpoints.lg} p="20" m="auto">
<Heading as="h3">MY Tiptap Editor</Heading>
<Spacer size={4} />
<Editor />
</Box>
);
}
export default App;
こちらを動かしてみます!
すごいシンプルなエディタですね…
メニューを追加する
Notionなどにあるような、選択されている文字を太字やItalicなど装飾ができるようなメニューを追加します。
@tiptap/extension-bubble-menu を利用します。
また、アイコンとして react-icons を利用します。
pnpm add react-icons
ではメニューのコンポーネントを作成します。
EditorBubbleMenu.tsx
を追加し、次のように作成します。
import { HStack, HStackProps } from "@kuma-ui/core";
import type { Editor } from "@tiptap/react";
import {
FaBold,
FaItalic,
FaStrikethrough,
FaUnderline,
} from "react-icons/fa6";
type Props = {
editor: Editor;
} & HStackProps;
const activeColor = "#1B88FF";
const inactiveColor = "#181E26";
export function EditorBubbleMenu({ editor, ...hstackProps }: Props) {
return (
<HStack
{...hstackProps}
gap={8}
background="colors.gray.100"
boxShadow="0 4px 4px 0 rgba(0, 0, 0, .2)"
borderRadius={4}
p={8}
>
<FaBold
color={editor.isActive("bold") ? activeColor : inactiveColor}
onClick={() => {
editor.chain().focus().toggleBold().run();
}}
size="12"
/>
<FaItalic
color={editor.isActive("italic") ? activeColor : inactiveColor}
onClick={() => {
editor.chain().focus().toggleItalic().run();
}}
size="12"
/>
<FaUnderline
color={editor.isActive("underline") ? activeColor : inactiveColor}
onClick={() => {
editor.chain().focus().toggleUnderline().run();
}}
size="12"
/>
<FaStrikethrough
color={editor.isActive("strike") ? activeColor : inactiveColor}
onClick={() => {
editor.chain().focus().toggleStrike().run();
}}
size="12"
/>
</HStack>
);
}
そしてエディタコンポーネントにメニューを追加します。
+ import { useEditor, EditorContent, BubbleMenu } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import { Box, css } from "@kuma-ui/core";
+ import { EditorBubbleMenu } from "./EditorBubbleMenu";
export function Editor() {
const editor = useEditor({
+ extensions: [StarterKit, Underline],
});
return (
<Box
border="1px solid #e5e7eb"
borderRadius="12px"
p="10"
height="100%"
width="100%"
boxShadow={["none", "0 2px 4px #4385bb12"]}
>
+ {editor && (
+ <BubbleMenu editor={editor} tippyOptions={{ duration: 100 }}>
+ <EditorBubbleMenu editor={editor} />
+ </BubbleMenu>
+ )}
<EditorContent editor={editor} className={editorContentStyle} />
</Box>
);
}
こちらを試してみます。
選択されるとメニューが表示され、またキーボードショートカットで装飾もされました! 🎉
カスタムノードを作成する
弊社のサービスであるテラーノベルはチャット形式の小説も投稿ができるサービスです。
テラーノベル
このように、会話文を入力できるエディタとなるように、カスタムノードをTiptapに追加してみましょう!
NodeViewを返すコンポーネントの作成
会話文を入力したときのTiptapのContentとして、次のような値が追加されるとします。
<message thumbnail-url="/teno.png" name="テノ">
テキスト
</message>
このようにコンテンツを追加、更新をするNodeViewを作成します。
import { colors } from "@/theme";
import { Box, HStack, VStack, Image, Input } from "@kuma-ui/core";
import type { NodeViewProps } from "@tiptap/react";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import { FaGripVertical } from "react-icons/fa6";
export function MessageNode({ node, updateAttributes }: NodeViewProps) {
// attributesからキャラクターのサムネイルの値を取得
const thumbnailUrl =
typeof node.attrs["thumbnail-url"] === "string"
? node.attrs["thumbnail-url"]
: "";
// attributesからキャラクターの名前の値を取得
const name = typeof node.attrs.name === "string" ? node.attrs.name : "";
return (
<NodeViewWrapper data-drag-handle>
<HStack p={4} mt={8} gap={4}>
<Box
draggable="true"
p={8}
display="flex"
alignItems="center"
cursor="pointer"
>
<FaGripVertical size="16" color={colors.gray[300]} />
</Box>
<HStack
alignItems="flex-start"
flex="1"
justifyContent="flex-start"
gap={8}
>
<VStack gap={8}>
{/* アイコン画像*/}
<Image
width={32}
height={32}
borderRadius={16}
src={thumbnailUrl}
/>
<Input
defaultValue={name}
textAlign="center"
border="none"
onBlur={(e) => {
// 入力された値をattributesに反映
updateAttributes({
name: e.target.value,
});
}}
width={32}
/>
</VStack>
<Box bg="colors.gray.100" borderRadius={16} minWidth={100} p={8}>
{/* 入力 */}
<NodeViewContent />
</Box>
</HStack>
</HStack>
</NodeViewWrapper>
);
}
Node.createでカスタムノードを作成する
先程のコンポーネントを Node.create
でカスタムノードとして作成します。
addAttributes
で属性値を定義、 parseHTML
で <message></message>
タグをパースし、 addNodeView
でコンポーネントを登録します。
import { mergeAttributes, Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { MessageNode } from "./MessageNode";
export const MessageContent = Node.create({
name: "message",
group: "block",
content: "inline*",
draggable: true,
addAttributes() {
return {
"thumbnail-url": {
default: null,
},
name: {
default: "名前",
},
};
},
parseHTML() {
return [
{
tag: "message",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["message", mergeAttributes(HTMLAttributes)];
},
addNodeView() {
return ReactNodeViewRenderer(MessageNode);
},
});
結果
このような感じになりました。
会話文の追加などもできていますね!
今回のソースコードはGitHubにも載せておきますので参考にどうぞ!
まとめ
Tiptapは既存のリッチなWYSIWYGエディタと比べて初期状態ではかなりシンプルなエディタとなります。
その分より柔軟性が高く、カスタムノードのようなカスタムのコンポーネントをエディタ内に追加することも可能となっています。
提供するエディタのカスタマイズ性を求めるならば、今回のTiptapやMetaが開発しているLexicalのようなHeadless Editor Frameworkを利用するのが良いと感じました。
それでは良いエディタ開発を!
Discussion