🙌

NextjsでEditorjsを使う

2022/07/25に公開

TL;DR

EditorTools.ts
import CheckList from "@editorjs/checklist";
import Delimiter from "@editorjs/delimiter";
import Embed from "@editorjs/embed";
import Header from "@editorjs/header";
import ImageTool from "@editorjs/image";
import LinkTool from "@editorjs/link";
import List from "@editorjs/list";
import Marker from "@editorjs/marker";
import Quote from "@editorjs/quote";
import Table from "@editorjs/table";
import { container } from "tsyringe";

import { fetchRoutes } from "@repositories/EditorRepository";
import { UploadFileForm } from "@schemas/validations/Editor/uploadFileForm";
import { UploadUrlForm } from "@schemas/validations/Editor/uploadUrlForm";
import EditorService from "@services/EditorService/EditorService";

const editorService = container.resolve(EditorService);
export const EditorTools = {
  header: {
    class: Header,
    shortcut: "CMD+SHIFT+H",
    config: {
      placeholder: "へッダー",
      levels: [1, 2, 3, 4],
      defaultLevel: 3,
    },
  },
  linkTool: {
    class: LinkTool,
    config: {
      endpoint: fetchRoutes.fetchLinkMeta,
    },
  },
  image: {
    class: ImageTool,
    config: {
      uploader: {
        uploadByFile(file: File) {
          const form: UploadFileForm = { image: file };
          return editorService.uploadFile(form).then((res) => res.data);
        },
        // only work when url has extensions like .jpg
        uploadByUrl(url: string) {
          const form: UploadUrlForm = { url };
          return editorService.uploadFileByUrl(form);
        },
      },
    },
  },
  checklist: {
    class: CheckList,
    inlineToolbar: true,
  },
  list: {
    class: List,
    inlineToolbar: true,
  },
  embed: {
    class: Embed,
    config: {
      services: {
        youtube: true,
        twitter: true,
      },
    },
  },
  quote: {
    class: Quote,
    inlineToolbar: true,
    shortcut: "CMD+SHIFT+O",
    config: {
      quotePlaceholder: "テキストを入力",
      captionPlaceholder: "キャプションを入力",
    },
  },
  delimiter: Delimiter,
  table: {
    class: Table,
    inlineToolbar: true,
    config: {
      rows: 2,
      cols: 3,
    },
  },
  marker: {
    class: Marker,
    shortcut: "CMD+SHIFT+M",
  },
};

export const i18n = {
  messages: {
    ui: {
      blockTunes: {
        toggler: {
          "Click to tune": "クリックして調整",
          "or drag to move": "ドラッグして移動",
        },
      },
      inlineToolbar: {
        converter: {
          "Convert to": "変換",
        },
      },
      toolbar: {
        toolbox: {
          Add: "追加",
        },
      },
    },
    toolNames: {
      Text: "テキスト",
      Heading: "タイトル",
      List: "リスト",
      Checklist: "チェックリスト",
      Quote: "引用",
      Delimiter: "直線",
      Table: "表",
      Link: "リンク",
      Bold: "太字",
      Italic: "斜体",
      Image: "画像",
      Marker: "マーカー",
    },
    blockTunes: {
      deleteTune: {
        Delete: "削除",
      },
      moveUpTune: {
        "Move up": "上に移動",
      },
      moveDownTune: {
        "Move down": "下に移動",
      },
    },
  },
};

Editor.tsx
import { useEffect, useRef, useState } from "react";

import EditorJS, { API, OutputData } from "@editorjs/editorjs";
import useId from "@mui/utils/useId";

import { EditorTools, i18n } from "@constants/EditorTools";

type ArticleEditorProps = {
  defaultValue: OutputData;
  placeholder?: string;
  readOnly?: boolean;
  minHeight?: number;
  onReady: () => void;
  onSave: (data: OutputData) => void;
  onChange: (api: API, event: CustomEvent) => void;
};

const ArticleEditor = ({
  defaultValue,
  placeholder,
  readOnly,
  minHeight,
  onReady,
  onChange,
  onSave,
}: ArticleEditorProps) => {
  const id = useId();
  const editorJS = useRef<EditorJS | null>(null);
  const [currentArticle, setCurrentArticle] = useState<OutputData | null>(null);
  useEffect(() => {
    if (editorJS.current === null) {
      editorJS.current = new EditorJS({
        placeholder,
        readOnly,
        minHeight,
        holder: id,
        data: defaultValue,
        i18n,
        tools: EditorTools,
        onChange(api: API, event: CustomEvent) {
          editorJS.current?.save().then((res) => {
            setCurrentArticle(res);
            onSave(res);
          });
          onChange(api, event);
        },
        onReady() {
          onReady();
        },
      });
    }
  }, []);
  useEffect(() => {
    console.log(currentArticle);
  }, [currentArticle]);
  return <div id={id} />;
};

ArticleEditor.defaultProps = {
  placeholder: "",
  readOnly: false,
  minHeight: 0,
};

export default ArticleEditor;
WhereUsingArtcleEditor.tsx
const ArticleEditor = dynamic(
  () => import("@components/ArticleEditor/ArticleEditor"),
  { ssr: false }
);
//中略
<ArticleEditor
  defaultValue={data}
  onChange={(api, event) => console.log("sample")}
  onReady={() => console.log("ready")}
  onSave={() => console.log("saved")}
/>

概要

reactでeditorjsを使うときには有志の方が作成したレポがあるのでそちらを使えるのかと持ったが、どうにも例通りに動かないのと、自分でメンテした方が良さそうなので、PureなEditorjsを導入するに至った。
https://github.com/Jungwoo-An/react-editor-js

四苦八苦

Editorjsを使うときに、一度インスタンス化してそれをステート管理すればええんやと思いやってみたものの、setStateActionでステートに入れたはずのEditorJSが変なタイミングでnullに戻ってしまうため断念した(多分レンダリング関係なのだけれど、React力が足りなくてなぜそうなるのかまで理解しきれていない)
*参考までにダメだったケース↓

BadExample.tsx
  const [isEditorReady, setIsEditorReady] = useState(false);
  const [editor, setEditor] = useState<EditorJS | null>(null);
  const [currentArticle, setCurrentArticle] = useState<OutputData | null>(null);
  const handleOnChange = async () => {
    let output: OutputData | null = null;
    if (editor) {
      output = await editor.save();
    }
    setCurrentArticle((prevArticle) => {
      if (output) {
        return output;
      }
      return prevArticle;
    });
  };
  useEffect(() => {
    console.log(editor)
  }, [editor]);
  useEffect(() => {}, [currentArticle]);
  useEffect(() => {
    setEditor((prevEditor) => {
      if (!prevEditor) {
        return new EditorJS({
          holder: id,
          data: defaultValue,
          tools: EditorTools,
          i18n,
          onChange(api: API, event: CustomEvent) {
            handleOnChange();
          },
          onReady() {
            setIsEditorReady(true);
          },
        });
      }
      return prevEditor;
    });
    return () => {
      if (editor) {
        editor.destroy();
      }
    };
  }, []);

react-editor-jsをベースに改修

react-editor-jsのソースを見にいきどうやってEditorJSのインスタンスを管理しているのか確認したところ、useRefをインスタンス変数的な使い方で利用しているのを見て作成したのが上記コード。
https://github.com/Jungwoo-An/react-editor-js/blob/master/packages/%40react-editor-js/core/src/ReactEditorJS.tsx

Reactライフサイクル難しい

ちょっと前までというか今もvueのoptionsAPI触っていると、自明ではないReactのライフサイクル難しいなあと思ったり、emitが恋しくなったりするけれど、DI的な思想のReactも好きなので少しづつ仲良くなっていきたい。

追記:親からEditorJSのメソッドを呼ぶ

useRefforwardRefuseImperativeHandleのセットでうまいくかと思いきや、親のref.currentが常に{current: retry()}となってしまうので、断念。別ライブラリではdynamic使うとなんとかなるでーという記述あったものの解決せず。
https://github.com/vasturiano/react-force-graph/issues/333
子コンポーネントで初期化する際に生成したインスタンスを親に渡して親もref参照できてしまうようにする方法で解決(Reacy Wayなのかは微妙、そもそもuseImperativeHandleも微妙なのだけれど)
雰囲気サンプルはこんな感じ↓↓

Sample.tsx
const Parent = () => {
  const editorRef = useRef<EditorJs>();
  //省略
  <ArticleEditor
    placeholder={t.WRITE_SOMETHING}
    onChange={handleChange}
    onReady={handleReady}
    onSave={handleSave}
    minHeight={200}
    onInitialize={(editor) => {
    editorRef.current = editor;
    }}
  />
}

ちなみにユースケースとしては、親コンポーネントがフォームで、子コンポーネントがエディターという状況の時に、Submit後に初期化したいとか、そんな感じ。

追記2:重複描画

EditorJsのブロックが重複描画される現象をひたすら調査したけれど、結局何で重複するのかはStrictModeだから以外に特定できなかった。ReactのDOM関連に影響してるのかなあと思いつつ、StrictModeじゃない時は期待通りの動作をしたので現状開発中は一時的に外して進めた。
StrictModeのオプトアウト欲しいね

Discussion