1. 記事一覧 >
  2. ブログ記事
category logo

Next.jsのSyntax Highlighter部分にファイル名表示、コピーボタン機能を追加

(更新) (公開)

はじめに

当ブログは、コード部分について、 react-markdownreact-syntax-highlighter を使っています。
この2つの npm の組み合わせだけでは、ファイル名表示とコピー機能が実装できません。
自力で実装しないといけません。


ファイル名表示とコピー機能とは、以下の部分の事です。

ファイル名表示とコピー機能


今回は、ファイル名表示とコピー機能を自力で実装しましたので、どうやって実装したかについて、紹介していきます。


ソースコード全体は、以下にあります。

https://github.com/itc-lab/itc-blog


ファイル名表示

前提

まず、markdown で書かれたブログ本文を react-markdown のコンポーネントで解析します。
コード部分のマークダウンは、以下のように書いています。(注意:先頭に全角スペースを入れていますが、実際は有りません。)

ブログ本文のコード部分
 ```tsx:components/Markdown.tsx
 export const Markdown = (props: { source: string }): ReactElement => {
   return (
     <ReactMarkdown
       key="content"
       plugins={[gfm]}
       source={props.source}
       renderers={{
         code: Code,
         heading: HeadingRenderer,
       }}
       escapeHtml={false}
     />
   );
 };
 ```

<ReactMarkdown /> コンポーネントの
renderersオプションに、{code: Code} を渡すことにより、
react-markdownは、コード部分(``````)の html 化を <Code /> コンポ―ネントに任せます。

ここは、元々こうなっていて、変更していません。変更したのは、<Code /> コンポ―ネントのコードです。


変更点

ファイル名表示に対応した結果が以下です。

components/Code.tsx
import React, { Fragment, FC } from 'react';
import { okaidia } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { escapeHtml } from '../libs/Functions';

interface Props {
  language: string;
  value: string;
}
const Code: FC<Props> = ({ language, value }) => {
  const [lang, filename] = (language || '').split(':');
  return (
    <Fragment>
      {filename && (
        <div className="code-block-filename-container">
          <span className="code-block-filename">{escapeHtml(filename)}</span>
        </div>
      )}
      <SyntaxHighlighter
        language={lang === '' ? 'javascript' : lang}
        style={okaidia}>
        {value}
      </SyntaxHighlighter>
    </Fragment>
  );
};
export default Code;

思っていたより、単純に済みました。


Step1

const [lang, filename] = (language || '').split(':');


language 部分は、``` 以降が渡されます。
例1:```typescript → language は、 typescript
例2:```typescript:filename → language は、 typescript:filename


これをコロン : の前後で分離しています。
(コロンが無い場合、filename は、undefined です。)

コロンで分離するというのは、マイルールです。


これで、filename が取り出せるようになりました。


Step2

{
  filename && (
    <div className="code-block-filename-container">
      <span className="code-block-filename">{escapeHtml(filename)}</span>
    </div>
  );
}


<SyntaxHighlighter /> コンポーネントのすぐ上に div タグを filename に値が有るときだけ表示しています。
escapeHtml は、libs/Functions.ts に定義した HTML に都合の悪い文字(&<>")をエスケープする関数です。

const HTML_ESCAPE_TEST_RE = /[&<>"]/;
const HTML_ESCAPE_REPLACE_RE = /[&<>"]/g;
const HTML_REPLACEMENTS: { [key: string]: string } = {
  '&': '&amp;',
  '<': '&lt;',
  '>': '&gt;',
  '"': '&quot;',
};
function replaceUnsafeChar(ch: string): string {
  return HTML_REPLACEMENTS[ch];
}
export function escapeHtml(str: string): string {
  if (HTML_ESCAPE_TEST_RE.test(str)) {
    return str.replace(HTML_ESCAPE_REPLACE_RE, replaceUnsafeChar);
  }
  return str;
}

Step3

あとは、追加した div タグに対して、css を定義しただけです。

.js-toc-content .code-block-filename-container .code-block-filename {
  display: table;
  max-width: 100%;
  background: #334155;
  color: #f8fafc;
  font-size: 0.875rem;
  line-height: 1.25rem;
  border-radius: 4px 4px 0 0;
  padding: 6px 12px 20px;
  padding-top: 6px;
  padding-right: 12px;
  padding-bottom: 20px;
  padding-left: 12px;
  margin-bottom: -20px;
}

ファイル名表示 diff

ファイル名表示部分の diff は、以下です。

ファイル名表示部分のdiff


コピーボタン

変更点

クリップボードにコピーする機能は、 react-copy-to-clipboard<CopyToClipboard /> コンポーネントを使っています。

これにより、ギミック部分だけを実装すれば良くなりました。
ギミック部分とは、以下の2つの機能です。
・コード部分にマウスカーソルが載ると、コピーボタンを表示(逆にコード部分以外にカーソルが出ていくと、コピーボタンを非表示)
・コピーボタンクリック時、"Copied!" の表示と消去

コピーボタン動作例


全体のソースコードは、以下です。先ほど、登場した <Code /> コンポーネント、Code.tsx で完結しています。

components/Code.tsx
import React, { Fragment, FC, useState } from 'react';
import { okaidia } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { escapeHtml } from '../libs/Functions';
import CopyToClipboard from 'react-copy-to-clipboard';

interface Props {
  language: string;
  value: string;
}
const Code: FC<Props> = ({ language, value }) => {
  const [lang, filename] = (language || '').split(':');

  const [showCopyToClipboard, setShowCopyToClipboard] = useState(false);
  const [styleTooltip, setStyleTooltip] = useState({
    opacity: '0',
    visiblity: 'hidden',
  });

  const handleClick = () => {
    setStyleTooltip({ opacity: '1', visiblity: 'visible' });
    setTimeout(function () {
      setStyleTooltip({ opacity: '0', visiblity: 'hidden' });
    }, 3000);
  };

  return (
    <Fragment>
      {filename && (
        <div className="code-block-filename-container">
          <span className="code-block-filename">{escapeHtml(filename)}</span>
        </div>
      )}
      <div
        style={{ position: 'relative' }}
        onMouseEnter={() => setShowCopyToClipboard(true)}
        onMouseLeave={() => setShowCopyToClipboard(false)}>
        <SyntaxHighlighter
          language={lang === '' ? 'javascript' : lang}
          style={okaidia}>
          {value}
        </SyntaxHighlighter>
        {showCopyToClipboard && (
          <div className="code-block-copy-button">
            <div className="copied-tooltip" style={styleTooltip}>
              Copied!
            </div>
            <CopyToClipboard text={value} onCopy={() => handleClick()}>
              <svg
                id="btnTarget"
                className="w-6 h-6"
                fill="none"
                stroke="currentColor"
                viewBox="0 0 24 24"
                xmlns="http://www.w3.org/2000/svg">
                <path
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  strokeWidth="2"
                  d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"></path>
              </svg>
            </CopyToClipboard>
          </div>
        )}
      </div>
    </Fragment>
  );
};
export default Code;

Step1

クリップボードへのコピー機能を実装します。
import CopyToClipboard from 'react-copy-to-clipboard';

クリップボードへのコピー機能を得たいアイコンに <CopyToClipboard /> コンポーネントを被せれば完成です。
これにより、クリックしたら、クリップボードへコピーするボタンが完成します。

<CopyToClipboard text={value} onCopy={() => handleClick()}>
  <svg ・・・/>
・・・
  </svg>
</CopyToClipboard>

text= に渡すのは、コピーしたい内容の文字列になります。これは、react-markdown から渡ってくるため、そのまま渡しています。


Step2
コード表示部分にカーソルが入るとコピーボタンを表示するようにします。

<div
  style={{ position: 'relative' }}
  onMouseEnter={() => setShowCopyToClipboard(true)}
  onMouseLeave={() => setShowCopyToClipboard(false)}>
  ・・・(コード表示部分)・・・
</div>

コード表示部分に <div> タグを被せて、
onMouseEnter(マウスカーソルが入ってくる) → showCopyToClipboard = true
onMouseLeave(マウスカーソルが出ていく) → showCopyToClipboard = false
としています。


showCopyToClipboard は、状態管理を行う React フックの useState() を使っています。

const [showCopyToClipboard, setShowCopyToClipboard] = useState(false);

で、「状態管理を行います。変数名は showCopyToClipboard で、showCopyToClipboard を変更する関数は、setShowCopyToClipboardshowCopyToClipboard の初期値は、false。」という意味です。


{showCopyToClipboard && (
  <div className="code-block-copy-button">
・・・

&& ( で、showCopyToClipboardtrue, false により、表示、非表示を切り替えています。


Step3

<CopyToClipboard text={value} onCopy={() => handleClick()}>

onCopy={() => handleClick()} により、クリップボードへのコピーが完了したら、"Copied!" を表示して、3秒後、消えるようにしています。
handleClick() の実装は、以下です。

const [styleTooltip, setStyleTooltip] = useState({
  opacity: '0',
  visiblity: 'hidden',
});

const handleClick = () => {
  setStyleTooltip({ opacity: '1', visiblity: 'visible' });
  setTimeout(function () {
    setStyleTooltip({ opacity: '0', visiblity: 'hidden' });
  }, 3000);
};

また状態管理を行う React フックの useState() を使っていますが、今度は、管理対象の値が true, false ではなく、連想配列(オブジェクト)になります。


{ opacity: '1', visiblity: 'visible' } : 表示
{ opacity: '0', visiblity: 'hidden' } : 非表示
と css の style を変数化して管理しています。

これを JSX の style= のところに渡すと、style が適用されます。

<div className="copied-tooltip" style={styleTooltip}>
  Copied!
</div>

なお、コピーボタンの位置、マウスポインタ―の指定や、"Copied!"の表示非表示がふんわり適用されるように以下のように CSS を追加しています。

.js-toc-content .code-block-copy-button {
  position: absolute;
  cursor: pointer;
  color: #9ca3af;
  top: 10px;
  right: 16px;
}

.js-toc-content .code-block-copy-button .copied-tooltip {
  cursor: default;
  position: absolute;
  top: 0px;
  right: 26px;
  padding: 0px 3px;
  display: inline-block;
  white-space: nowrap;
  background: #374151;
  color: #f9fafb;
  border-radius: 3px;
  transition: 0.3s ease-in;
}

コピーボタン機能 diff

コピーボタン機能部分の diff は、以下です。

コピーボタン機能部分のdiff


以上!

loading...