📖

Next.jsで作成したMarkdownエディタにツイート埋め込み機能を実装しよう

2024/07/25に公開

はじめに

今回は前回作成したMarkdownエディタにX(旧: Twitter)のツイート埋め込み機能を実装します。

前回の内容については、以下の記事をご覧ください。
https://zenn.dev/milky/articles/markdown-content

環境

  • remark-directive - 3.0.0
  • unist-util-visit - 5.0.0
  • react-twitter-widgets - 1.11.0
  • @fortawesome/fontawesome-free - 6.6.0

実装

実装に必要なライブラリの説明

  • remark-directive (GitHub)
    Markdown に拡張構文(ディレクティブ)を追加するためのプラグイン。

  • unist-util-visit (GitHub)
    unist ノードを走査するためのユーティリティ。

  • react-twitter-widgets (GitHub)
    このライブラリを使用してツイートを埋め込みます。

  • @fortawesome/fontawesome-free (GitHub)
    ツイート埋め込みボタンのアイコンを設定するときに使用します。

必要なライブラリをインストールします。

npm install remark-directive unist-util-visit react-twitter-widgets @fortawesome/fontawesome-free

1. 前回実装した内容をComponentに切り分け

まず前回実装したMarkdownEditor.tsxファイルの内容を以下を参考にSimpleMdeEditor.tsxPreviewMarkdown.tsxComponentに切り分けます。

エディタ(MarkdownEditor.tsx)
MarkdownEditor.tsx
- import { useEffect, useMemo, useState } from "react";
- import dynamic from "next/dynamic";
- const ReactSimpleMdeEditor = dynamic(() => import("react-simplemde-editor"), {
  ssr: false,
});
- import "easymde/dist/easymde.min.css";

- import ReactMarkdown from "react-markdown";
- import breaks from "remark-breaks";
- import remarkGfm from "remark-gfm";
- import "github-markdown-css/github-markdown.css";
+ import { useEffect, useState } from 'react';
+ import { PreviewMarkdown } from './PreviewMarkdown';
+ import { SimpleMdeEditor } from './SimpleMdeEditor';

export const MarkdownEditor = () => {
  const [markdownValue, setMarkdownValue] = useState("");

  useEffect(() => {
    const savedContent = localStorage.getItem("smde_saved_content") ?? "";
    setMarkdownValue(savedContent);
  }, []);

  const onChange = (value: string) => {
    setMarkdownValue(value);
  };

- const options = useMemo(() => {
-   return {
-     autofocus: true,
-     spellChecker: false,
-     autosave: {
-       enabled: true,
-       uniqueId: "saved_content",
-       delay: 1000,
-     },
-   };
- }, []);

  return (
    <>
-     <ReactSimpleMdeEditor value={markdownValue} onChange={onChange} options={options} />
-     <h1 className="text-4xl font-bold mb-4 text-left pl-4">プレビュー</h1>
-     <div
-       className="markdown-body p-4 border border-gray-300 h-100 overflow-y-auto"
-       style={{ fontFamily: "inherit", fontSize: "inherit" }}
-     >
-       <ReactMarkdown remarkPlugins={[remarkGfm, breaks]}>{markdownValue}</ReactMarkdown>
-     </div>
+     <SimpleMdeEditor markdownValue={markdownValue} onChange={onChange} />
+     <PreviewMarkdown markdownValue={markdownValue} />
    </>
  );
};
Markdownエディタ(SimpleMdeEditor.tsx)
SimpleMdeEditor.tsx
import { useMemo } from "react";
import dynamic from "next/dynamic";
const ReactSimpleMdeEditor = dynamic(() => import("react-simplemde-editor"), {
  ssr: false,
});
import "easymde/dist/easymde.min.css";

type Props = {
  markdownValue: string;
  onChange: (value: string) => void;
};

export const SimpleMdeEditor = ({ markdownValue, onChange }: Props) => {
  const options = useMemo(() => {
    return {
      autofocus: true,
      spellChecker: false,
      autosave: {
        enabled: true,
        uniqueId: "saved_content",
        delay: 1000,
      },
    };
  }, []);
  return (
    <>
      <ReactSimpleMdeEditor
        value={markdownValue}
        onChange={onChange}
        options={options}
      />
    </>
  );
};
プレビュー画面(PreviewMarkdown.tsx)
PreviewMarkdown.tsx
import ReactMarkdown from 'react-markdown';
import breaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import 'github-markdown-css/github-markdown.css';

export const PreviewMarkdown = ({ markdownValue }: { markdownValue: string }) => {
  return (
    <>
      <h1 className="text-4xl font-bold mb-4 text-left pl-4">プレビュー</h1>
      <div
        className="markdown-body p-4 border border-gray-300 h-100 overflow-y-auto"
        style={{ fontFamily: 'inherit', fontSize: 'inherit' }}
      >
        <ReactMarkdown remarkPlugins={[remarkGfm, breaks]}>{markdownValue}</ReactMarkdown>
      </div>
    </>
  );
};

2. ツールバーをカスタマイズ

toolbarオプションを追加していない場合は下のようにデフォルトのツールバーが設定されます。


以下のようにツールバーの設定をオプションに追加します。

SimpleMdeEditor.tsx
import { useMemo } from "react";
import dynamic from "next/dynamic";
import "easymde/dist/easymde.min.css";
const ReactSimpleMdeEditor = dynamic(() => import("react-simplemde-editor"), {
  ssr: false,
});

type Props = {
  markdownValue: string;
  onChange: (value: string) => void;
};

export const SimpleMdeEditor = ({ markdownValue, onChange }: Props) => {
+ const toolbarOptions = [
+   {
+     name: "heading",
+     action: (editor: any) => editor.toggleHeadingSmaller(),
+     className: "fa fa-header",
+     title: "見出し",
+   },
+   {
+     name: "bold",
+     action: (editor: any) => editor.toggleBold(),
+     className: "fa fa-bold",
+     title: "太字",
+   },
+ ];

  const options = useMemo(() => {
    return {
      autofocus: true,
      spellChecker: false,
+     toolbar: toolbarOptions,
      autosave: {
        enabled: true,
        uniqueId: "saved_content",
        delay: 1000,
      },
    };
  }, []);

  return (
    <>
      <ReactSimpleMdeEditor
        value={markdownValue}
        onChange={onChange}
        options={options}
      />
    </>
  );
};

今回はシンプルにheadingboldボタンを追加しました。

3. ツイート埋め込みタグの挿入ボタン追加

次にツイート埋め込みタグの挿入ボタンを追加します。

SimpleMdeEditor.tsx
import { useMemo } from "react";
import dynamic from "next/dynamic";
import "easymde/dist/easymde.min.css";
const ReactSimpleMdeEditor = dynamic(() => import("react-simplemde-editor"), {
  ssr: false,
});
+ import "@fortawesome/fontawesome-free/css/all.min.css";

type Props = {
  markdownValue: string;
  onChange: (value: string) => void;
};

export const SimpleMdeEditor = ({ markdownValue, onChange }: Props) => {
+ const handleInsertTwitter = (editor: any) => {
+   // 挿入するテキストを作成する
+   const insertText = ":twitter[]";
+   // エディタのCodeMirrorインスタンスを取得
+   const cm = editor.codemirror;
+   // カーソル位置にテキストを挿入
+   cm.replaceRange(insertText, cm.getCursor());
+   // エディタにフォーカスを設定
+   cm.focus();
+ };

  const toolbarOptions = [
    {
      name: "heading",
      action: (editor: any) => editor.toggleHeadingSmaller(),
      className: "fa fa-header",
      title: "見出し",
    },
    {
      name: "bold",
      action: (editor: any) => editor.toggleBold(),
      className: "fa fa-bold",
      title: "太字",
    },
+   {
+     name: "twitter",
+     action: handleInsertTwitter,
+     className: "fa-brands fa-x-twitter",
+     title: "X (旧: Twitter)",
+   },
  ];

  const options = useMemo(() => {
    return {
      autofocus: true,
      spellChecker: false,
      toolbar: toolbarOptions,
      autosave: {
        enabled: true,
        uniqueId: "saved_content",
        delay: 1000,
      },
    };
  }, []);

  return (
    <>
      <ReactSimpleMdeEditor
        value={markdownValue}
        onChange={onChange}
        options={options}
      />
    </>
  );
};

これで下のようにツイート埋め込みボタンが追加され、ツイート埋め込みタグが挿入されるようになりました。


以下の部分について簡単に説明します。

const handleInsertTwitter = (editor: EasyMDE) => {
  // 挿入するテキストを作成する
  const insertText = "::twitter[]";
  // エディタのCodeMirrorインスタンスを取得
  const cm = editor.codemirror;
  // カーソル位置にテキストを挿入
  cm.replaceRange(insertText, cm.getCursor());
  // エディタにフォーカスを設定
  cm.focus();
};

const insertText = ":twitter[]";
挿入するテキストを定義しています。この場合、:twitter[] という文字列がカーソルの位置に挿入されます。

const cm = editor.codemirror;
EasyMDEエディタのCodeMirrorインスタンスを取得しています。EasyMDEはCodeMirrorを内部で使用しているため、これを介してテキストの操作を行います。

cm.replaceRange(insertText, cm.getCursor());
replaceRange メソッドを使用して、カーソル位置cm.getCursor()insertTextで指定したテキストを挿入します。

cm.focus();
テキストの挿入後、エディタにフォーカスを設定します。

4. ツイートを表示させる

次に、入力されたツイートIDに基づいてツイートを表示する処理を追加します。

ツイートIDとは

ツイートIDとは、URL の末尾にある数字部分で、status パラメータの後に続く数字がツイートのIDです。例えば、以下のURLの場合、ツイートIDは 1133841266427346945 です。
https://x.com/Support/status/1133841266427346945

PreviewMarkdown.tsx
import ReactMarkdown from 'react-markdown';
import breaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import 'github-markdown-css/github-markdown.css';
+ import remarkDirective from 'remark-directive';
+ import { visit } from 'unist-util-visit';
+ import { Tweet } from 'react-twitter-widgets';

export const PreviewMarkdown = ({ markdownValue }: { markdownValue: string }) => {
+  const remarkLeafDirective = () => {
+    return (tree: any) => {
+      visit(tree, 'leafDirective', (node) => {
+        if (node.name === 'twitter') {
+          const valueChild = node.children.find((child: any) => 'value' in child);
+          if (valueChild) {
+            const { value } = valueChild;
+            node.value = <Tweet tweetId={value} />;
+            node.type = 'html';
+          }
+        }
+      });
+    };
+  };

  return (
    <>
      <h1 className="text-4xl font-bold mb-4 text-left pl-4">プレビュー</h1>
      <div
        className="markdown-body p-4 border border-gray-300 h-100 overflow-y-auto"
        style={{ fontFamily: 'inherit', fontSize: 'inherit' }}
      >
        <ReactMarkdown
          remarkPlugins={[
            remarkGfm,
            breaks,
+           remarkDirective,
+           remarkLeafDirective,
          ]}
        >
          {markdownValue}
        </ReactMarkdown>
      </div>
    </>
  );
};

これで下のように入力したツイートID基づいたツイートが表示されるようになりました。


以下の部分について簡単に説明します。

const remarkLeafDirective = () => {
  return (tree: any) => {
    visit(tree, "leafDirective", (node) => {
      if (node.name === "twitter") {
        const valueChild = node.children.find((child: any) => "value" in child);
        if (valueChild) {
          const { value } = valueChild;
          node.value = <Tweet tweetId={value} />;
          node.type = "html";
        }
      }
    });
  };
};

return (tree: any) => { ... }
この部分は、remark ツリーを受け取る関数を返します。この関数はツリー全体を処理し、ツリー内のノードを訪問するために visit 関数を使います。

visit(tree, 'leafDirective', (node) => { ... })
visit 関数は、remark ツリー内のノードを再帰的に訪問し、指定されたノードタイプ(ここでは 'leafDirective')に一致するノードに対してコールバック関数を実行します。

node.value = <Tweet tweetId={value} />;
node.value を JSX の <Tweet tweetId={value} /> に設定します。これにより、ノードの内容が Tweet コンポーネントに置き換えられます。

node.type = 'html';
ノードのタイプを 'html' に設定します。これにより、ノードが HTML としてレンダリングされるようになります。

さいごに

今回は前回作成したMarkdownエディタをカスタマイズして、ツイート埋め込み機能の実装方法を解説しました。

Discussion