プログラミング

メモアプリ開発④リッチテキストエディタ【Tiptap】

この記事は約29分で読めます。

メモアプリ「Simpl」の開発手順をまとめるシリーズ
第4弾はメモのリッチテキストエディタ機能です。


これまでの学習内容や開発記録については以下の記事にまとめています。

Tiptapライブラリ

今回はリッチテキストエディタを実装するためにTiptapライブラリを使用します。

TiptapライブラリはWYSIWYGエディタを実装するためのフレームワークです。
従来のものよりも導入しやすいだけでなく拡張もしやすいことで人気が出てきているそうです。

WYSIWYG(ウィジウィグ)エディタ
What You See Is What You Get(見たままが得られる)の頭文字が由来で、太字下線などのマークアップを編集しながらプレビューできるエディターのことです。

React | Tiptap Editor Docs
Learn how to integrate the Tiptap Editor with a React app and develop your custom editor experience.

【React】フロントエンド処理の追加

変更点の概要

今回の変更を一言でいうと

メモ本文部分をtextareaタグからTiptapを使ったカスタムコンポーネントに変更

です。

リッチテキストエディタを実装したPopupEditorコンポーネントとその子コンポーネントについて解説していきます。

PopupEditorコンポーネント

PopupEditorコンポーネントは、リッチテキストエディタを提供するカスタムコンポーネントです。

ライブラリコンポーネント = ライブラリに含まれているコンポーネント
カスタムコンポーネント = 自分で作るコンポーネント

以下のコンポーネントを使用しています。

EditorContentコンポーネント

EditorContentコンポーネントはエディタ本体を提供するライブラリコンポーネントです。

(抜粋)src/components/Editor/PopupEditor.tsx
<EditorContent editor={editor} />

propsにエディタインスタンスeditor)を渡します。

エディタインスタンスは、以下のようなテキストエディタの設定を定義します。

  • 機能:太字や下線、取り消し線などの修飾機能を定義します。
  • 表示データ:エディタに表示するデータを設定します。
  • イベントハンドラ:エディタが更新された際の処理やフォーカスが外れた時の処理を定義します。

(抜粋)src/components/Editor/PopupEditor.tsx
const editor = useEditor({
    // エディタの機能
    extensions: [ 
      Document,
      History,
      Paragraph,
      Text,
      Link.configure({
        openOnClick: false,
      }),
      Bold,
      Underline,
      Italic,
      Strike,
      Code,
      Placeholder.configure({
        placeholder: "Enter Content",
      }),
    ],
    // エディタの表示データ
    content: currentMemo.content, 
    // イベントハンドラ
    onUpdate: ({ editor }) => { 
      setCurrentMemo({ ...currentMemo, content: editor.getHTML() });
    },
    onBlur: () => {
      handleUpdateMemo(currentMemo);
      setEditingMemoId(-1);
    },
  }) as Editor;

BubbleMenuコンポーネント

BubbleMenuコンポーネントはテキストのコンテキストメニューを提供するライブラリコンポーネントです。

今回は以下の2つの用途でインスタンスを作成しています。

テキスト選択時メニュー

テキストを選択すると太字や下線をつけるコンテキストメニューが表示されます。

太字アイコンや下線アイコンを記述しています。

(抜粋)src/components/Editor/PopupEditor.tsx
<BubbleMenu
  pluginKey="bubbleMenuText"
  className="editor__bubble-menu"
  tippyOptions={{ duration: 150, placement: "top" }}
  editor={editor}
  shouldShow={({ editor, view, state, oldState, from, to }) => {
    return from !== to;
  }}
>
  <button
    className={classNames("editor__button editor__button--icon", {
      "is-active": editor.isActive("link"),
    })}
    onClick={openModal}
  >
    <IconTooltip title="リンク">
      <LinkIcon />
    </IconTooltip>
  </button>
  <button
    className={classNames("editor__button editor__button--icon", {
      "is-active": editor.isActive("bold"),
    })}
    onClick={toggleBold}
  >
    <IconTooltip title="太字">
      <FormatBoldIcon />
    </IconTooltip>
  </button>
  <button
    className={classNames("editor__button editor__button--icon", {
      "is-active": editor.isActive("underline"),
    })}
    onClick={toggleUnderline}
  >
    <IconTooltip title="下線">
      <FormatUnderlinedIcon />
    </IconTooltip>
  </button>
  <button
    className={classNames("editor__button editor__button--icon", {
      "is-active": editor.isActive("strike"),
    })}
    onClick={toggleStrike}
  >
    <IconTooltip title="取り消し線">
      <StrikethroughSIcon />
    </IconTooltip>
  </button>
  <button
    className={classNames("editor__button editor__button--icon", {
      "is-active": editor.isActive("code"),
    })}
    onClick={toggleCode}
  >
    <IconTooltip title="コード">
      <CodeIcon />
    </IconTooltip>
  </button>
</BubbleMenu>

また、リンクをクリックするとリンクに関するコンテキストメニューが表示されます。

編集アイコンと開くアイコンを記述しています。

(抜粋)src/components/Editor/PopupEditor.tsx
<BubbleMenu
  pluginKey="bubbleMenuLink"
  className="editor__bubble-menu"
  tippyOptions={{ duration: 150, placement: "bottom" }}
  editor={editor}
  shouldShow={({ editor, view, state, oldState, from, to }) => {
    return from === to && editor.isActive("link");
  }}
>
  <button
    className="editor__button editor__button--icon"
    onClick={openModal}
  >
    <IconTooltip title="編集">
      <EditIcon />
    </IconTooltip>
  </button>
  <button
    className="editor__button editor__button--icon"
    onClick={() => window.open(editor.getAttributes("link").href)}
  >
    <IconTooltip title="開く">
      <OpenInNewIcon />
    </IconTooltip>
  </button>
</BubbleMenu>

LinkModalコンポーネントはリンク設定モーダルを提供するカスタムコンポーネントです。

Tiptapのエディタインスタンスは現在のエディタの状態を管理しているため、選択されたテキストの範囲を把握しています。そのため選択されたテキストをaタグで囲うことができます。

既に作成済みの「アカウント削除モーダル」と共通ブロックを使用しています。

src/components/Editor/LinkModal.tsx
import React from "react";
import ReactModal from "react-modal";
import { Modal } from "./Modal";
import LinkOffIcon from "@mui/icons-material/LinkOff";
import AddLinkIcon from "@mui/icons-material/AddLink";

interface IProps extends ReactModal.Props {
  url: string;
  closeModal: () => void;
  onChangeUrl: (e: React.ChangeEvent<HTMLInputElement>) => void;
  onSaveLink: (e: React.MouseEvent<HTMLButtonElement>) => void;
  onRemoveLink: (e: React.MouseEvent<HTMLButtonElement>) => void;
}

export function LinkModal(props: IProps) {
  const { url, closeModal, onChangeUrl, onSaveLink, onRemoveLink, ...rest } =
    props;
  return (
    <Modal {...rest} className="link-modal">
      <h2 className="link-modal__title">リンクを編集</h2>
      <input
        className="common__form common__form--input"
        name="url"
        type="text"
        placeholder="リンクを入力"
        value={url}
        onChange={onChangeUrl}
      />
      <div className="link-modal__button-box">
        <button
          className="common__form common__form--button link-modal__button link-modal__button"
          onClick={onRemoveLink}
        >
          <LinkOffIcon />
          <p>リンクを削除する</p>
        </button>
        <button
          className="common__form common__form--button link-modal__button link-modal__button"
          onClick={onSaveLink}
        >
          <AddLinkIcon />
          <p>リンクを保存する</p>
        </button>
      </div>
    </Modal>
  );
}

IconTooltipコンポーネント

IconTooltipコンポーネントはアイコンのツールチップを提供するカスタムコンポーネントです。

Material UI (MUI) のTooltipコンポーネントを使用しています。

React Tooltip component - Material UI
Tooltips display informative text when users hover over, focus on, or tap an element.

src/components/Editor/IconTooltop.tsx
import { Tooltip } from "@mui/material";
import React, { ReactElement } from "react";

interface IconTooltipProps {
  title: string;
  children: ReactElement;
}

const IconTooltip: React.FC<IconTooltipProps> = ({ title, children }) => {  
  return (
    <Tooltip
      title={
        <div>
          <p
            style={{
              fontSize: "1rem",
              color: "var(--white)",
              fontWeight: "bold",
            }}
          >
            {title}
          </p>
        </div>
      }
      placement="top"
      componentsProps={{
        popper: {
          sx: {
            zIndex: 10000, // BubbleMenuのデフォルトが9999
          },
        },
      }}
    >
      {children}
    </Tooltip>
  );
};

export default IconTooltip;

作成済みのメモ削除ボタンにも適用しました。


最後にPopupEditorコンポーネントの全体コードを記載します。

src/components/Editor/PopupEditor.tsx
import { useCallback, useEffect, useState } from "react";
import classNames from "classnames";
import LinkIcon from "@mui/icons-material/Link";
import EditIcon from "@mui/icons-material/Edit";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import FormatBoldIcon from "@mui/icons-material/FormatBold";
import FormatUnderlinedIcon from "@mui/icons-material/FormatUnderlined";
import StrikethroughSIcon from "@mui/icons-material/StrikethroughS";
import CodeIcon from "@mui/icons-material/Code";

// Tiptap packages
import { useEditor, EditorContent, Editor, BubbleMenu } from "@tiptap/react";
import Document from "@tiptap/extension-document";
import Paragraph from "@tiptap/extension-paragraph";
import Text from "@tiptap/extension-text";
import Link from "@tiptap/extension-link";
import Bold from "@tiptap/extension-bold";
import Underline from "@tiptap/extension-underline";
import Italic from "@tiptap/extension-italic";
import Strike from "@tiptap/extension-strike";
import Code from "@tiptap/extension-code";
import History from "@tiptap/extension-history";
import Placeholder from "@tiptap/extension-placeholder";

// カスタム
import { LinkModal } from "./LinkModal";
import { Memo } from "../../Types";
import { useMemoContext } from "../../context/MemoContext";
import { Tooltip } from "@mui/material";
import IconTooltip from "./IconTooltop";

interface EditorProps {
  currentMemo: Memo;
  setCurrentMemo: React.Dispatch<React.SetStateAction<Memo>>;
}

const PopupEditor: React.VFC<EditorProps> = ({
  currentMemo,
  setCurrentMemo,
}) => {
  const { setEditingMemoId, handleUpdateMemo } = useMemoContext();
  // 実装するエディタの設定
  const editor = useEditor({
    // エディタ機能の設定
    extensions: [
      Document,
      History,
      Paragraph,
      Text,
      Link.configure({
        openOnClick: false,
      }),
      Bold,
      Underline,
      Italic,
      Strike,
      Code,
      Placeholder.configure({
        placeholder: "Enter Content", // プレースホルダーテキストを設定
      }),
    ],
    // エディタの初期内容
    content: currentMemo.content,
    // 更新処理の設定
    onUpdate: ({ editor }) => {
      setCurrentMemo({ ...currentMemo, content: editor.getHTML() });
    },
    // Blur時の処理
    onBlur: () => {
      handleUpdateMemo(currentMemo);
      setEditingMemoId(-1);
    },
  }) as Editor;
  const [modalIsOpen, setIsOpen] = useState(false);
  const [url, setUrl] = useState<string>("");

  const openModal = useCallback(() => {
    setUrl(editor.getAttributes("link").href);
    setIsOpen(true);
  }, [editor]);

  const closeModal = useCallback(() => {
    setIsOpen(false);
    setUrl("");
  }, []);

  const saveLink = useCallback(() => {
    // urlに値が入ってる場合はリンクを設定
    if (url) {
      editor
        .chain()
        .focus()
        .extendMarkRange("link")
        .setLink({ href: url, target: "_blank" })
        .run(); // chainで設定したコマンドを実行
    } else {
      // urlに値が入ってない場合はリンクを削除
      editor.chain().focus().extendMarkRange("link").unsetLink().run();
    }
    editor.commands.blur();
    closeModal();
  }, [editor, url, closeModal]);

  const removeLink = useCallback(() => {
    editor.chain().focus().extendMarkRange("link").unsetLink().run();
    closeModal();
  }, [editor, closeModal]);

  const toggleBold = useCallback(() => {
    editor.chain().focus().toggleBold().run();
  }, [editor]);

  const toggleUnderline = useCallback(() => {
    editor.chain().focus().toggleUnderline().run();
  }, [editor]);

  const toggleStrike = useCallback(() => {
    editor.chain().focus().toggleStrike().run();
  }, [editor]);

  const toggleCode = useCallback(() => {
    editor.chain().focus().toggleCode().run();
  }, [editor]);

  if (!editor) {
    return null;
  }

  return (
    <div className="editor editor-mini">
      {/* 文字列選択時に表示されるメニュー */}
      <BubbleMenu
        pluginKey="bubbleMenuText"
        className="editor__bubble-menu"
        tippyOptions={{ duration: 150, placement: "top" }}
        editor={editor}
        shouldShow={({ editor, view, state, oldState, from, to }) => {
          return from !== to;
        }}
      >
        <button
          className={classNames("editor__button editor__button--icon", {
            "is-active": editor.isActive("link"),
          })}
          onClick={openModal}
        >
          <IconTooltip title="リンク">
            <LinkIcon />
          </IconTooltip>
        </button>
        <button
          className={classNames("editor__button editor__button--icon", {
            "is-active": editor.isActive("bold"),
          })}
          onClick={toggleBold}
        >
          <IconTooltip title="太字">
            <FormatBoldIcon />
          </IconTooltip>
        </button>
        <button
          className={classNames("editor__button editor__button--icon", {
            "is-active": editor.isActive("underline"),
          })}
          onClick={toggleUnderline}
        >
          <IconTooltip title="下線">
            <FormatUnderlinedIcon />
          </IconTooltip>
        </button>
        <button
          className={classNames("editor__button editor__button--icon", {
            "is-active": editor.isActive("strike"),
          })}
          onClick={toggleStrike}
        >
          <IconTooltip title="取り消し線">
            <StrikethroughSIcon />
          </IconTooltip>
        </button>
        <button
          className={classNames("editor__button editor__button--icon", {
            "is-active": editor.isActive("code"),
          })}
          onClick={toggleCode}
        >
          <IconTooltip title="コード">
            <CodeIcon />
          </IconTooltip>
        </button>
      </BubbleMenu>

      {/* リンク選択時に表示されるメニュー */}
      <BubbleMenu
        pluginKey="bubbleMenuLink"
        className="editor__bubble-menu"
        tippyOptions={{ duration: 150, placement: "bottom" }}
        editor={editor}
        shouldShow={({ editor, view, state, oldState, from, to }) => {
          return from === to && editor.isActive("link");
        }}
      >
        <button
          className="editor__button editor__button--icon"
          onClick={openModal}
        >
          <IconTooltip title="編集">
            <EditIcon />
          </IconTooltip>
        </button>
        <button
          className="editor__button editor__button--icon"
          onClick={() => window.open(editor.getAttributes("link").href)}
        >
          <IconTooltip title="開く">
            <OpenInNewIcon />
          </IconTooltip>
        </button>
      </BubbleMenu>

      {/* エディタ本体 */}
      <EditorContent editor={editor} />

      <LinkModal
        url={url}
        isOpen={modalIsOpen}
        onRequestClose={closeModal}
        contentLabel="Edit Link Modal"
        closeModal={closeModal}
        onChangeUrl={(e) => setUrl(e.target.value)}
        onSaveLink={saveLink}
        onRemoveLink={removeLink}
      />
    </div>
  );
};

export default PopupEditor;

まとめ

今回はリッチテキストエディタ機能を題材に、Tiptapライブラリを使用したカスタムコンポーネントについて解説しました。

少しずつ使い勝手が良くなってきて、個人的なTodo管理にも使い出しました。

参考文献

コメント