🙆‍♀️

【React】リッチエディタを作る時はheadlessエディタフレームワークのTiptapが良さそう

2023/12/17に公開

はじめに

最近しずかなインターネットの技術構成が公開されましたね。そこでエディタ機能にTiptapを採用されていることを知りました。

https://zenn.dev/catnose99/articles/f8a90a1616dfb3#エディタ

今回は実際にheadlessエディタフレームワークのTiptapを触ってみての所感や調査内容を残そうと思います。

Tiptapとは

Tiptapは「リッチテキストWYSIWYGエディタ」を構築するためのツールキットである「ProseMirror」のヘッドレスラッパーで、New York Times, The Guardian, Atlassianといった多くの有名企業ですでに利用されています。

※WYSIWYG(ウィジウィグ)= What You See Is What You Getの略で、「見たままを得られる」という意味です。

https://tiptap.dev/introduction

活用事例

株式会社PR TIMESでは2022年4月時点で使われている模様です。

https://developers.prtimes.jp/2022/04/15/how-we-choose-react-based-wysiwyg-editor-at-pr-times/

株式会社microCMSはTiptapのスポンサーとして参画されており、エディタ機能としてTiptapを採用しています。

https://blog.microcms.io/became-sponsor-tiptap-headless-editor/

UIフレームワークだとmantineも使っていてドキュメントにも「@mantine/tiptapはTiptap用のUIを提供します」と明記されています。

https://mantine.dev/others/tiptap/

補足

紹介であったようにTiptapはProseMirrorをラップしています。そのProseMirrorをエディタライブラリとして株式会社ヌーラボは採用しているようです。

https://nulab.com/ja/blog/backlog/building-text-editor-with-prosemirror/

環境構築

公式ドキュメントではCreate React Appを使用していますが、今回はReact, TypeScript, Viteでサンプルの実装を進めます。

$ npm create vite
$ npm install
$ npm run dev
Tiptapをインストールする
$ npm install @tiptap/react @tiptap/pm @tiptap/starter-kit

これでTiptap(React, TypeScript)の環境構築は完了です。

starter-kitを採用しているので以降は公式ドキュメントに倣って実装をするとかなり具体的な機能を確認することができます。

https://tiptap.dev/installation/react

ちなみにstarter-kitに入っている拡張機能一覧は以下に記載されています。

https://tiptap.dev/api/extensions/starter-kit

次の章ではstarter-kitでは採用されていない画像アップロード機能(DD込み)について触れようと思います。

画像アップロード機能を導入する

先ほど紹介したstarter-kitには画像アップロード機能は含まれていないため開発者側で用意する必要があります。拡張機能をインストールしましょう。

npm install @tiptap/extension-image

https://tiptap.dev/api/nodes/image

ドキュメントに沿って実装を進めます。今回の味噌としては実装の都合上base64を許容する拡張を施したくらいでしょうか。

addImage.ts
import { Editor } from "@tiptap/react";

export function addImage({ src, editor }: { src: string; editor: Editor }) {
  editor.chain().focus().setImage({ src }).run();
}
tiptapClient.ts
import TiptapImage from "@tiptap/extension-image";

export const Image = TiptapImage.extend({
  defaultOptions: {
    ...TiptapImage.options,
    allowBase64: true,
  },
});

使用したコンポーネントの紹介

DDWrapper.tsx
import { useRef } from "react";
import { Editor } from "@tiptap/react";
import { addImage } from "../../../functions/helpers/addImage";
import { getDataUrl } from "../../../functions/helpers/getDataUrl";
import { useDD } from "../../../functions/hooks/useDD";

export function DDWrapper({
  children,
  editor,
}: {
  children: React.ReactNode;
  editor: Editor;
}) {
  const dragRef = useRef<HTMLDivElement | null>(null);

  useDD(dragRef, async (e) => {
    const files = e.dataTransfer?.files;
    const src = await getDataUrl({ files });

    if (!src) {
      alert("error");
      return;
    }

    addImage({ src, editor });
  });
  return <div ref={dragRef}>{children}</div>;
}
Menu.tsx
import { Editor } from "@tiptap/react";
import { BaseSyntheticEvent } from "react";
import { AiOutlineUpload } from "react-icons/ai";
import { addImage } from "../../../functions/helpers/addImage";
import { getDataUrl } from "../../../functions/helpers/getDataUrl";

export function Menu({ editor }: { editor: Editor }) {
  const handleUpload = async (e: BaseSyntheticEvent) => {
    const files = e.target.files;
    const src = await getDataUrl({ files });

    if (!src) {
      alert("error");
      return;
    }

    addImage({ src, editor });
  };

  return (
    <label>
      <AiOutlineUpload />
      <input type="file" onChange={handleUpload} hidden />
    </label>
  );
}
index.tsx
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { DDWrapper } from "../sample/components/DDWrapper";
import { Menu } from "../sample/components/Menu";
import { content } from "../../functions/constants/content_01";
import { Image } from "../../functions/utilities";

export function Sample() {
  const editor = useEditor({
    extensions: [StarterKit, Image],
    content: content,
  });

  const handleSubmit = () => {
    if (!editor) return;
    const html = editor.getHTML();
    alert(html);
  };

  if (!editor) return <p>loading...</p>;

  return (
    <div>
      <Menu editor={editor} />
      <DDWrapper editor={editor}>
        <EditorContent editor={editor} />
      </DDWrapper>
      <button onClick={handleSubmit} type="button">submit</button>
    </div>
  );
}

使用した機能の紹介

useDD.ts
import { RefObject, useCallback, useEffect } from "react";

export const useDD = (
  dragRef: RefObject<HTMLElement>,
  callback: (e: DragEvent) => void
) => {
  const handleDragIn = useCallback((e: DragEvent): void => {
    e.preventDefault();
    e.stopPropagation();
  }, []);

  const handleDragOut = useCallback((e: DragEvent): void => {
    e.preventDefault();
    e.stopPropagation();
  }, []);

  const handleDragOver = useCallback((e: DragEvent): void => {
    e.preventDefault();
    e.stopPropagation();
  }, []);

  const handleDrop = useCallback(
    (e: DragEvent): void => {
      e.preventDefault();
      e.stopPropagation();

      callback(e);
    },
    [callback]
  );

  const initDragEvents = useCallback((): void => {
    if (dragRef.current !== null) {
      dragRef.current.addEventListener("dragenter", handleDragIn);
      dragRef.current.addEventListener("dragleave", handleDragOut);
      dragRef.current.addEventListener("dragover", handleDragOver);
      dragRef.current.addEventListener("drop", handleDrop);
    }
  }, [handleDragIn, handleDragOut, handleDragOver, handleDrop, dragRef]);

  const resetDragEvents = useCallback((): void => {
    if (dragRef.current !== null) {
      dragRef.current.removeEventListener("dragenter", handleDragIn);
      dragRef.current.removeEventListener("dragleave", handleDragOut);
      dragRef.current.removeEventListener("dragover", handleDragOver);
      dragRef.current.removeEventListener("drop", handleDrop);
    }
  }, [handleDragIn, handleDragOut, handleDragOver, handleDrop, dragRef]);

  useEffect(() => {
    initDragEvents();

    return () => resetDragEvents();
  }, [initDragEvents, resetDragEvents]);
};
getDataUrl.ts
export async function getDataUrl({
  files,
}: {
  files: FileList | undefined;
}): Promise<string | null> {
  if (!files) return null;
  const file = files[0];
  const reader = new FileReader();
  reader.readAsDataURL(file);
  await new Promise((resolve) => (reader.onload = () => resolve("")));
  return reader.result as string;
}

仕上げ

上記を組み立て最後に画像アップロードをしてエディタに画像が表示されるかを確認します。

うまくいっているようですね。ログにも出力されていることを確認できました。

最後に

Tiptapを使えば簡単にリッチエディタを作れることがわかりました。

今回の記事では取り上げなかったのですがTiptapの強みは「headless」なところなのでUIを自由に変更できるところにあります。拡張機能も豊富にあるのでスピード感を持って実装できますし、TypeScriptもサポートされているのは嬉しいですね。

実は、今回紹介した画像アップロード機能ですがTiptap公式が機能提供しています。しかしPro Extensionと明記されているように課金して初めて使用できるようになるので注意が必要になります。

https://tiptap.dev/api/extensions/file-handler

検証はできていないのですが、TiptapとY.jsの拡張機能も提供されていてリアルタイム編集(スプシみたいな挙動)が実装できます。次のリンクにサンプルも載っているので是非ご覧ください。

https://tiptap.dev/api/extensions/collaboration

駆け足になりましたが、最後までご覧いただきありがとうございました。この記事がどなたかの参考になりましたら幸いです。

参考記事

https://medium.com/@650egor/simple-drag-and-drop-file-upload-in-react-2cb409d88929

https://zenn.dev/dqn/articles/7505cfa1bed278#fn-398c-1

Discussion