メモアプリ「Simpl」の開発手順をまとめるシリーズ
第4弾はメモのリッチテキストエディタ機能です。
これまでの学習内容や開発記録については以下の記事にまとめています。
Tiptapライブラリ
今回はリッチテキストエディタを実装するためにTiptapライブラリを使用します。
TiptapライブラリはWYSIWYGエディタを実装するためのフレームワークです。
従来のものよりも導入しやすいだけでなく拡張もしやすいことで人気が出てきているそうです。
【React】フロントエンド処理の追加
変更点の概要
今回の変更を一言でいうと
です。
リッチテキストエディタを実装したPopupEditorコンポーネントとその子コンポーネントについて解説していきます。
PopupEditorコンポーネント
PopupEditorコンポーネントは、リッチテキストエディタを提供するカスタムコンポーネントです。
以下のコンポーネントを使用しています。
- EditorContentコンポーネント(エディタ本体を提供する)
- BubbleMenuコンポーネント(テキスト付近のメニューを提供する)
- LinkModalコンポーネント(リンクを編集するモーダルを提供する)
- IconTooltipコンポーネント(アイコンにツールチップを提供する)
EditorContentコンポーネント
EditorContentコンポーネントはエディタ本体を提供するライブラリコンポーネントです。
<EditorContent editor={editor} />
propsにエディタインスタンス(editor)を渡します。
エディタインスタンスは、以下のようなテキストエディタの設定を定義します。
- 機能:太字や下線、取り消し線などの修飾機能を定義します。
- 表示データ:エディタに表示するデータを設定します。
- イベントハンドラ:エディタが更新された際の処理やフォーカスが外れた時の処理を定義します。
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つの用途でインスタンスを作成しています。
テキスト選択時メニュー
テキストを選択すると太字や下線をつけるコンテキストメニューが表示されます。
太字アイコンや下線アイコンを記述しています。
<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>
LinkModalコンポーネント
LinkModalコンポーネントはリンク設定モーダルを提供するカスタムコンポーネントです。
Tiptapのエディタインスタンスは現在のエディタの状態を管理しているため、選択されたテキストの範囲を把握しています。そのため選択されたテキストをaタグで囲うことができます。
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コンポーネントを使用しています。
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コンポーネントの全体コードを記載します。
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ライブラリを使用したカスタムコンポーネントについて解説しました。
参考文献
Tiptapを導入する際に参考にしました。
Material UI (MUI) のTooltipに関するページです。
コメント