YuTa Extend

オリジナル楽曲とIT系個人開発紹介のブログ

【Next.js】next-mdx-remoteでインラインコードとコードブロックコピーボタンを実装する

  2022-12-15

【Next.js】next-mdx-remoteでインラインコードとコードブロックコピーボタンを実装する

next-mdx-remote でインラインコードとコードブロックコピーボタンを実装する

前回の記事では、next-mdx-remote で作られた MDX のブログにファイル名つきシンタックスハイライトを実装しました。 今回はさらに、インラインコードとコードブロックコピーボタンを実装します。

next-mdx-remote の導入やコードブロックの表示方法については、前回の記事を参照ください。

PostPage.tsx
Copied!!
import type { InferGetStaticPropsType, NextPage } from "next";
import { ReactNode } from "react";
import path from "path";
import fs from "fs";
import matter from "gray-matter";
import { serialize } from "next-mdx-remote/serialize";
import { MDXRemote } from "next-mdx-remote";
import CodeBlock from "CodeBlock";

export async function getStaticProps() {
  const postFilePath = path.join(process.cwd(), "public/sample.mdx");
  const source = fs.readFileSync(postFilePath);
  const { content, data } = matter(source);
  const mdxSource = await serialize(content, {
    mdxOptions: {
      remarkPlugins: [],
      rehypePlugins: [],
    },
    scope: data,
  });

  return {
    props: {
      mdxSource: mdxSource,
    },
  };
}

const components = {
  code: (props: JSX.IntrinsicAttributes & { children?: ReactNode }) => (
    <CodeBlock {...props} />
  ),
};

type Props = InferGetStaticPropsType<typeof getStaticProps>;
const PostPage: NextPage<Props> = (props: Props) => {
  return <MDXRemote {...props.mdxSource} components={components} />;
};

export default PostPage;

インラインコードを実装する

インラインコードですが、そのまま書くとコードブロックと区別がつかないらしく、うまく実装する方法が以下のリンク先に書かれています。 <code>タグの親の<pre>タグをCodeBlock.tsxコンポーネントに渡すと、コードブロックかどうか判定できるみたいですね。

Copied!!
import CodeBlock from "./CodeBlock";

const components = {};

components.pre = CodeBlock;

export default components;
CodeBlock.tsx
Copied!!
import React from 'react'
import Highlight, { defaultProps } from 'prism-react-renderer'
import vsDark from 'prism-react-renderer/themes/vsDark'

const CodeBlock = ({ children }) => {
	if (!children || children.type !== 'code') return null

	const {
		props: { className, children: code = '' },
	} = children

	const language = className ? className.replace(/language-/, '') : ''

	return (
		<Highlight
			{...defaultProps}
			theme={vsDark}
			code={code.trim()}
			language={language}
		>
			{({ className, style, tokens, getLineProps, getTokenProps }) => (
				<pre className={className} style={{ ...style, padding: '20px' }}>
					{tokens.map((line, i) => (
						<div key={i} {...getLineProps({ line, key: i })}>
							{line.map((token, key) => (
								<span key={key} {...getTokenProps({ token, key })} />
							))}
						</div>
					))}
				</pre>
			)}
		</Highlight>
	)
}

export default CodeBlock

ただし上記のやり方では TypeScript に対応していないため、本記事では TypeScript に対応した書き方にしてみます。 もともと React.ReactNode 型だった入力をコードブロック用の型に落とし込むため、型ガード という構文を使ってみます。

PostPage.tsx
Copied!!
import type { InferGetStaticPropsType, NextPage } from "next";
import { ReactNode } from "react";
import path from "path";
import fs from "fs";
import matter from "gray-matter";
import { serialize } from "next-mdx-remote/serialize";
import { MDXRemote } from "next-mdx-remote";
import CodeBlock from "CodeBlock";

export async function getStaticProps() {
  const postFilePath = path.join(process.cwd(), "public/sample.mdx");
  const source = fs.readFileSync(postFilePath);
  const { content, data } = matter(source);
  const mdxSource = await serialize(content, {
    mdxOptions: {
      remarkPlugins: [],
      rehypePlugins: [],
    },
    scope: data,
  });

  return {
    props: {
      mdxSource: mdxSource,
    },
  };
}

// code ⇒ preに変更
const components = {
  pre: (props: JSX.IntrinsicAttributes & { children?: ReactNode }) => (
    <CodeBlock {...props} />
  ),
};

type Props = InferGetStaticPropsType<typeof getStaticProps>;
const PostPage: NextPage<Props> = (props: Props) => {
  return <MDXRemote {...props.mdxSource} components={components} />;
};

export default PostPage;
CodeBlock.tsx
Copied!!
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";

interface Code {
  props: { className: string; children: string };
  type: string;
}
type Props = {
  children?: Code | React.ReactNode;
};
// 型ガード関数
function isCodeBlock(children: any): children is Code {
  return children.type === "code";
}
const CodeBlock: React.FC<Props> = ({ children }: Props) => {
  // コードブロックでない場合は終了
  if (!children || !isCodeBlock(children)) {
    return null;
  }

  // コードブロックの各要素を設定
  const match = /language-(\w+)(:?.*)/.exec(children.props.className || "");
  const language = match && match[1] ? match[1] : "";
  const fileName = match && match[2] ? match[2].slice(1) : "";
  const code = String(children.props.children).replace(/\n$/, "");
  const syntaxHighlighterClass = fileName
    ? "code-block-with-title"
    : "code-block";
  return (
    <>
      <div className="code-block-wrapper">
        {fileName && <div className="code-block-title">{fileName}</div>}
        <SyntaxHighlighter
          language={language}
          style={atomDark}
          className={syntaxHighlighterClass}
        >
          {code}
        </SyntaxHighlighter>
      </div>
      <style jsx>{`
        .code-block-wrapper {
          font-size: 0.9rem;
          margin-bottom: 2rem;
        }
        .code-block-title {
          display: inline-block;
          border-radius: 0.3rem 0.3rem 0 0;
          background-color: #323e52;
          padding: 0.55rem 1rem;
          color: white;
          font-size: 0.8rem;
          font-family: Inconsolata, Monaco, Consolas, "Courier New", Courier,
            monospace;
        }
      `}</style>
      <style jsx global>{`
        .code-block {
          border-radius: 0.3rem !important;
          padding: 1.5rem !important;
        }
        .code-block-with-title {
          border-radius: 0 0.3rem 0.3rem 0.3rem !important;
          padding: 1.5rem !important;
          margin-top: 0 !important;
        }
      `}</style>
    </>
  );
};

export default CodeBlock;

これでコードブロックとインラインコードの判定ができました。

あとは装飾ですが、インラインコードは<p>タグの子になっていて、コードブロックでは逆にそうならないはずなので、PostPage.tsx側に styled-jsx を global で書いておきます。

CodeBlock.tsx側に書きたいところですが、CodeBlock.tsxが一度も呼び出されないと CSS も呼び出されないので、仕方ありませんがPostPage.tsx側に書きます)

PostPage.tsx
Copied!!
  ~ 省略 ~
type Props = InferGetStaticPropsType<typeof getStaticProps>;
const PostPage: NextPage<Props> = (props: Props) => {
  return (
    <>
      <MDXRemote {...props.mdxSource} components={components} />
      <style jsx global>{`
        p code {
          background-color: #eee;
          padding: 0.3rem;
          margin: 0 0.2rem;
          border-radius: 0.3rem;
          font-family: Inconsolata, Monaco, Consolas, "Courier New", Courier,
            monospace;
        }
      `}</style>
    </>
  );
};

export default PostPage;

これで無事にインラインコードの装飾もできました。

コードブロックコピーボタンを実装する

次にコピーボタンの実装です。クリップボードにテキストをコピーするには react-copy-to-clipboard というプラグインを使うのが便利です。

以下のコマンドで react-copy-to-clipboard をインストールします。

Copied!!
> npm i react-copy-to-clipboard
> npm i --save-dev @types/react-copy-to-clipboard

また、コピーボタンにアイコンを使いたい場合は、react-icons というプラグインを使うのが便利です。

以下のコマンドで react-icons をインストールします。

Copied!!
> npm i react-icons

そして以下のサイトで使いたいアイコンを探し、サイト内のサンプルコードの通りに参照すればアイコンを表示できます。

コピーボタンの実装についてですが、 「コードブロックにカーソルが入るとコピーボタンの表示/非表示を切り替える」 「コピーボタンを押すとコピー完了メッセージを出す」 といった要件で作ります。

このように状態に応じて要素や装飾に動きをつける場合は「フック(React Hooks)」という機能を使います。 使い方は以下のコードでご確認ください。 (コードのuseState()の部分でフックを使っています)

一通り使う技術を紹介したところで、CodeBlock.tsxの全文がこちらになります。

CodeBlock.tsx
Copied!!
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { useState } from "react";
import CopyToClipboard from "react-copy-to-clipboard";
import { BiCheck, BiCopy } from "react-icons/bi";

interface Code {
  props: { className: string; children: string };
  type: string;
}
type Props = {
  children?: Code | React.ReactNode;
};
// 型ガード関数
function isCodeBlock(children: any): children is Code {
  return children.type === "code";
}
const CodeBlock: React.FC<Props> = ({ children }: Props) => {
  // コピーボタンの処理
  const [isButtonActive, setIsButtonActive] = useState(false);
  const normalStyle = {
    opacity: 0,
    transition: "0.1s",
  };
  const activeStyle = {
    opacity: 1,
    transition: "0.1s",
  };
  const copyBtnStyle = isButtonActive ? activeStyle : normalStyle;

  // コピー完了メッセージの処理
  const [isCopied, setCopied] = useState(false);
  const handleClick = () => {
    setCopied(true);
    setTimeout(() => {
      setCopied(false);
    }, 3000);
  };
  const copiedStyle = isCopied ? activeStyle : normalStyle;

  // コードブロックでない場合は終了
  if (!children || !isCodeBlock(children)) {
    return null;
  }

  // コードブロックの各要素を設定
  const match = /language-(\w+)(:?.*)/.exec(children.props.className || "");
  const language = match && match[1] ? match[1] : "";
  const fileName = match && match[2] ? match[2].slice(1) : "";
  const code = String(children.props.children).replace(/\n$/, "");
  const syntaxHighlighterClass = fileName
    ? "code-block-with-title"
    : "code-block";
  return (
    <>
      <div>
        {fileName && <div className="code-block-title">{fileName}</div>}
        <div
          className="code-block-wrapper"
          onMouseEnter={() => setIsButtonActive(true)}
          onMouseLeave={() => setIsButtonActive(false)}
        >
          <div className="copied-tooltip" style={copiedStyle}>
            Copied!!
          </div>
          <div className="copy-button" style={copyBtnStyle}>
            <CopyToClipboard text={code} onCopy={() => handleClick()}>
              {isCopied ? (
                <BiCheck color="grey" size={20} />
              ) : (
                <BiCopy color="grey" size={20} />
              )}
            </CopyToClipboard>
          </div>
          <SyntaxHighlighter
            language={language}
            style={atomDark}
            className={syntaxHighlighterClass}
          >
            {code}
          </SyntaxHighlighter>
        </div>
      </div>
      <style jsx>{`
        .code-block-wrapper {
          font-size: 0.9rem;
          margin-bottom: 2rem;
          position: relative;
        }
        .code-block-title {
          display: inline-block;
          border-radius: 0.3rem 0.3rem 0 0;
          background-color: #323e52;
          padding: 0.55rem 1rem;
          color: white;
          font-size: 0.8rem;
          font-family: Inconsolata, Monaco, Consolas, "Courier New", Courier,
            monospace;
        }
        .copy-button {
          display: inline-block;
          position: absolute;
          top: 0.8rem;
          right: 0.8rem;
          background-color: rgba(50, 50, 50, 0.1);
          border: 1px solid grey;
          border-radius: 0.3rem;
          padding: 0.2rem;
        }
        .copy-button:hover {
          background-color: rgba(50, 50, 50, 0.9);
          cursor: pointer;
        }
        .copied-tooltip {
          position: absolute;
          top: 0.8rem;
          right: 3.2rem;
          color: white;
          background-color: rgba(50, 50, 50, 0.5);
          border-radius: 0.2rem;
          padding: 0.3rem;
        }
      `}</style>
      <style jsx global>{`
        .code-block {
          border-radius: 0.3rem !important;
          padding: 1.5rem !important;
        }
        .code-block-with-title {
          border-radius: 0 0.3rem 0.3rem 0.3rem !important;
          padding: 1.5rem !important;
          margin-top: 0 !important;
        }
      `}</style>
    </>
  );
};

export default CodeBlock;

一点注意点ですが、フックの処理は分岐や return 文の前に処理しなければならない制約があります。 このサンプルではコードブロックかどうかの判定でreturn null;を使っていますので、その前にフックの処理を記述しないと ESLint に怒られるので気を付けましょう。

(特にこだわりがなければ、ブロックの最初の方に処理を書いておくのがおすすめです)

まとめ

前回の記事では、next-mdx-remote で作られた MDX のブログにファイル名つきシンタックスハイライトを実装し、 今回はそれに対してインラインコードとコードブロックコピーボタンを実装しました。

個人的にはこれで満足いくデザインのコードブロックに仕上がりました。

次は next-mdx-remote の導入をちゃんと解説した記事を作りたいと思います。

関連記事