🎃

reactとnext.jsで実装するSummernoteエディタ

2024/07/24に公開

クライアントにリッチテキストエディタを組み込んで欲しいとの要望があったので、react/next.jsとSummernoteで作ってみました。

直接JqueryやBootstrapなんかを読み込んでいるのは、Summernoteとのバージョンが違う?らしく僕の環境ではうまく動作しなかったためです。。。

作成

下記コードをコピペすれば動作します(多分。。)

app/sample/page.jsx
    "use client";
    import React, { useEffect, useRef, useState } from 'react';
    import { useRouter } from 'next/navigation';
    
    
    const MyComponent = () => {
  // Summernoteエディタへの参照
  const editorRef = useRef(null);

  // テキストエリアの内容とボタンの有効/無効状態を管理するための状態
  const [textareaContents, setTextareaContents] = useState(""); 
  const [isButtonDisabled, setIsButtonDisabled] = useState(true); 

  // フォーム送信後のルーティング用
  const router = useRouter();

  useEffect(() => {
    // 外部スクリプトとスタイルシートを読み込む関数
    const loadScripts = () => {
      // スクリプトを動的に読み込む関数
      const loadScript = (src, callback) => {
        const script = document.createElement('script');
        script.src = src;
        script.async = true;
        script.onload = callback;
        document.body.appendChild(script);
      };

      // CSSスタイルシートを動的に読み込む関数
      const loadCSS = (href) => {
        const link = document.createElement('link');
        link.rel = 'stylesheet';
        link.href = href;
        document.head.appendChild(link);
      };

      // カスタムインラインCSSを動的に追加する関数
      const addInlineCSS = (css) => {
        const style = document.createElement('style');
        style.innerHTML = css;
        document.head.appendChild(style);
      };

      if (typeof window !== 'undefined') {
        // Bootstrap CSSを読み込む
        loadCSS('https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css');

        // Summernote CSSを読み込む
        loadCSS('https://cdnjs.cloudflare.com/ajax/libs/summernote/0.8.18/summernote-bs4.min.css');

        // Summernote用のカスタムインラインCSSを追加
        addInlineCSS(`
          .note-editor .note-editable {
            font-size: 14px; /* デフォルトのフォントサイズを14pxに設定 */
            line-height: 1.8; /* 行間を調整 */
          }
          .note-editor .note-editable p {
            margin: 0; /* 段落のマージンをリセット */
            padding: 0; /* 段落のパディングをリセット */
          }
          .note-editor .note-editable br {
            line-height: 1.8; /* 改行の行間も調整 */
          }
        `);

        // jQueryとその他の依存関係を順に読み込む
        loadScript('https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js', () => {
          loadScript('https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js', () => {
            loadScript('https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/js/bootstrap.min.js', () => {
              loadScript('https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.js', () => {
                loadScript('https://cdn.jsdelivr.net/npm/summernote@0.8.20/src/lang/summernote-ja-JP.js', () => {
                  // Japanese language support for Summernoteを初期化
                  if (window.jQuery && window.jQuery.fn.summernote) {
                    const $editor = window.jQuery(editorRef.current);
                    $editor.summernote({
                      height: 800,
                      lang: "ja-JP",
                      newline: '<br>',
                      toolbar: [
                        ['style', ['style']],
                        ["fontsize", ["fontsize"]],
                        ['font', ['bold', 'italic', 'underline']],
                        ['color', ['color']],
                        ['insert', ['link', 'picture']]
                      ],
                      callbacks: {
                        // コンテンツ変更時にボタンの状態を更新
                        onChange: (contents) => {
                          updateButtonState(contents);
                        },
                        // 画像のアップロードを処理
                        onImageUpload: (files) => {
                          if (files.length > 0) {
                            uploadImage(files[0]);
                          }
                        },
                      }
                    });
                  }
                });
              });
            });
          });
        });
      }
    };

    // 画像をサーバーにアップロードする関数
    const uploadImage = async (file) => {
      const formData = new FormData();
      formData.append('image', file);

      try {
        // サーバーにファイルを送信
        const response = await fetch("/api/upload_image", {
          method: "POST",
          body: formData,
        });

        const data = await response.json();
        
        // アップロードが成功したか確認
        if (data.url) {
          // Summernoteに画像URLを挿入
          const $editor = window.jQuery(editorRef.current);
          $editor.summernote('insertImage', data.url);
        } else {
          alert('画像のアップロードに失敗しました。');
        }
      } catch (error) {
        console.error('Error:', error);
        alert('画像のアップロード中にエラーが発生しました。');
      }
    };

    loadScripts();

    // コンポーネントがアンマウントされるときにSummernoteとその他のスクリプトをクリーンアップ
    return () => {
      if (window.jQuery && window.jQuery.fn.summernote) {
        const $editor = window.jQuery(editorRef.current);
        $editor.summernote('destroy'); // Summernoteを破棄
      }
      document.querySelectorAll('script[src]').forEach((script) => {
        if (
          script.src.includes('jquery') ||
          script.src.includes('bootstrap') ||
          script.src.includes('popper') ||
          script.src.includes('summernote')
        ) {
          document.body.removeChild(script);
        }
      });

      document.querySelectorAll('link[href]').forEach((link) => {
        if (link.href.includes('bootstrap') || link.href.includes('summernote')) {
          document.head.removeChild(link);
        }
      });
    };
  }, []);

  // コンテンツが空かどうか、または画像が含まれているかに基づいてボタンの状態を更新
  const updateButtonState = (contents) => {
    // コンテンツが空または空白のみ、かつ画像が含まれていない場合はボタンを無効化
    const hasImages = /<img[^>]*>/i.test(contents); // 画像が含まれているか確認
    const cleanedContents = contents.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
    setIsButtonDisabled(cleanedContents === "" && !hasImages);
  };

  // フォーム送信時の処理
  const handleSubmit = async (event) => {
    event.preventDefault();

    // Summernoteの内容を取得して状態に設定
    if (window.jQuery && window.jQuery.fn.summernote) {
      let content = window.jQuery(editorRef.current).summernote('code');
      
      // 特殊文字を元に戻す
      content = content
        .replace(/&lt;/g, '<')
        .replace(/&gt;/g, '>')
        .replace(/&amp;/g, '&');
      
      // <iframe>タグの幅を100%、高さをautoに設定
      content = content.replace(/<iframe([^>]*)>/gi, '<iframe$1 style="width: 100%; height: auto;"></iframe>');
      
      console.log("Raw Content:", content); // デバッグ用
      setTextareaContents(content);

      // サーバー処理
      const response = await fetch("./api/summernote_file_api", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ textarea_contents: content }),
      });

      // レスポンスの確認など
      const data = await response.json();
      console.log(data);

      window.jQuery(editorRef.current).summernote('reset');
      setTextareaContents("");
      setIsButtonDisabled(true); // ボタンを再度無効化
      router.push('/'); // 保存後トップページに遷移
    }
  };

  return (
    <>
      <form onSubmit={handleSubmit}>
        <div className="p-2 m-0">
          <div ref={editorRef}></div> {/* Summernote用の<div> */}
        </div>

        <div className="p-2" style={{ borderTop: "1px solid #e1e1e1", width: "100%", position: "fixed", bottom: "0", left: "0", background: "#fff" }}>
          <button className="btn btn-success w-100" type="submit" disabled={isButtonDisabled}>保存する</button>
        </div>
      </form>
    </>
  );
};

    export default MyComponent;

上記でこんな感じで表示されるはずです。

なお、fetchによるPOST先URLは自身の環境に合わせて変更してください。

また、summernoteの入力エリアにYouTube動画の埋め込み<iframe>タグを貼り付ける際、自動でエンコードされてしまうので、

content = content
        .replace(/&lt;/g, '<')
        .replace(/&gt;/g, '>')
        .replace(/&amp;/g, '&');

で置き換えによるデコードをしています。

以上ー

Discussion