✍️

Tiptapでオリジナルエディタをつくろう!

2023/10/06に公開

こんにちは!テラーノベルでiOS/Android/Webとフロントエンド周りを担当している @kazutoyoです!

今回はHeadless Editor FrameworkであるTiptapのご紹介と、そちらを使ってオリジナルなエディタを作成しようと思います。

Tiptapとは

TiptapはHeadless Editor Frameworkと呼ばれています。

これまでWebでのWYSIWYGエディタとしてQuill.jsDraft.jsなどが存在しました。

これらは文字の装飾や文章の編集に関するUIをデフォルトで提供しており、エディタに特別な機能を追加しようとしたり、UIを大きく変えるというのは少々大変でした。

TiptapのようなHeadless Editor Frameworkは、エディタで必要な機能のコア機能を提供し、必要なUIや機能は拡張機能として追加できるような仕組みになっています。

これにより、エディタを提供したい人にとって柔軟性の高いエディタライブラリとなっています。

https://tiptap.dev/editor

TiptapはProseMirrorというエディタをベースにしています。
そのため、TiptapからProseMirrorのAPIを呼び出すことも可能です。

また、TipTap EditorはTypeScriptで開発されており、Vanilla JS/React/Vue/Svelteなど様々な開発環境で利用することができます。

エディタライブラリは歴史が長いものが多かったりして、意外とTSの対応がされていなかったりするので嬉しいですね。

Tiptapの拡張性

TiptapはHeadless Editorであるため、デフォルトの状態ではよくあるようなWYSIWYGエディタのような機能やUIはありません。
TiptapはExtensionとしてそのようなUIや機能を追加できるようになっています。
https://tiptap.dev/extensions

この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を追加します。

vite.config.ts
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の設定などを行います。

Editor.tsx
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 に追加しておきます。

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 を追加し、次のように作成します。

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>
  );
}

そしてエディタコンポーネントにメニューを追加します。

Editor.tsx
+ 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を作成します。

MessageNode.tsx
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 でコンポーネントを登録します。

MessageContentExtension.ts
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にも載せておきますので参考にどうぞ!
https://github.com/kazutoyo/my-tiptap-editor

まとめ

Tiptapは既存のリッチなWYSIWYGエディタと比べて初期状態ではかなりシンプルなエディタとなります。
その分より柔軟性が高く、カスタムノードのようなカスタムのコンポーネントをエディタ内に追加することも可能となっています。
提供するエディタのカスタマイズ性を求めるならば、今回のTiptapやMetaが開発しているLexicalのようなHeadless Editor Frameworkを利用するのが良いと感じました。

それでは良いエディタ開発を!

テラーノベル テックブログ

Discussion