Torihaji's Growth Diary

Little by little, no hurry.

Next.jsでreact-simplemde-editorを魔改造(笑)する上で詰まったこと

はじめに

現在、個人開発projectでEditorを使っています。

そのEditorはreact-simplemde-editorを使っているのですが、

初期値だけのdefaultだとしょぼいというか味気ないので

魔改造(笑)してます。私なりに頑張ったので魔です🤪

で、それをする上で色々詰まったので

それを色々書き残してこうかと思います。

前提

"next": "15.3.1",
"react": "^19.0.0",
"react-hook-form": "^7.56.1",
"easymde": "^2.20.0",
"react-simplemde-editor": "^5.2.0",

クライアントコンポーネント、dynamic-importを使いましょう。

最初、公式みたいに次のように書いていました。

"use client";

import "easymde/dist/easymde.min.css";
import SimpleMDE from "react-simplemde-editor";

export const MarkdownEditor = ({
  value,
  onChange,
}: {
  value: string;
  onChange: (value: string) => void;
}) => {
  return <SimpleMDE value={value} onChange={onChange} />;
};

それをreact-hook-formと絡める感じですね。

"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { createItem } from "@/lib/items/actions";
import { ItemCreateForm, itemCreateSchema } from "@/schema/item-schema";

import { MarkdownEditor } from "./markdown-editor";

export const CreateItemForm = () => {
  const router = useRouter();
  const form = useForm<ItemCreateForm>({
    resolver: zodResolver(itemCreateSchema),
    defaultValues: {
      title: "",
      content: "",
    },
  });

  const onSubmit = async (data: ItemCreateForm) => {
    try {
      const item = await createItem(data);
      toast.success("Successfully created item");
      router.push(`/items/${item.id}`);
    } catch (error) {
      toast.error("Failed to create item");
      console.error(error);
    }
  };

  return (
    <Form {...form}>
      <form
        onSubmit={form.handleSubmit(onSubmit)}
        className="flex flex-col w-full"
      >
        <div className="flex gap-4">
          <FormField
            control={form.control}
            name="title"
            render={({ field }) => (
              <FormItem className="grow mb-4">
                <FormControl>
                  <Input
                    type="text"
                    placeholder="Box Name"
                    className="text-3xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-4xl outline-none bg-transparent w-fit min-w-[300px]"
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <Button type="submit" className="cursor-pointer">
            Create
          </Button>
        </div>
        <FormField
          control={form.control}
          name="content"
          render={({ field }) => (
            <FormItem>
              <FormControl>
                <MarkdownEditor
                  value={field.value ?? ""}
                  onChange={field.onChange}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
      </form>
    </Form>
  );
};

それを一番上のpage.tsxで使います。

import { CreateItemForm } from "@/features/items/create-item-form";

export default async function CreateItemPage() {
  return (
    <>
      <div className="flex flex-col items-start">
        <CreateItemForm />
      </div>
    </>
  );
}

で画面はこんな感じ。

でだ。この状態だと次のようなerrorが出ます。

document is not defined。

定番ですね。

ということでこれをdynamic-importで直します。

"use client";

import dynamic from "next/dynamic";
import "easymde/dist/easymde.min.css";

const SimpleMDE = dynamic(() => import("react-simplemde-editor"), {
  ssr: false,
});

export const MarkdownEditor = ({
  value,
  onChange,
}: {
  value: string;
  onChange: (value: string) => void;
}) => {

  return <SimpleMDE value={value} onChange={onChange} />;
};

とすれば治ります。

困った時はoptionsを覗きましょう。

editor系はoptionsを設定したら化けます。

私もまだ全てを把握できませんが、TypeScriptで型定義情報が覗けるので

公式と見比べながらあーだこーだしていきましょう。

型定義があるので設定が捗ると思います。

コマンドショートカットは大文字で。

というかそもそもoptionsはどのように設定するかご存知ですか。

私はまだいじるかもしれませんが、一旦現時点はこんな感じです。

"use client";

import { useMemo } from "react";
import dynamic from "next/dynamic";
import "easymde/dist/easymde.min.css";
import { useSidebar } from "@/components/ui/sidebar";
import { SimpleMDEReactProps } from "react-simplemde-editor";
const SimpleMDE = dynamic(() => import("react-simplemde-editor"), {
  ssr: false,
});

export const MarkdownEditor = ({
  value,
  onChange,
}: {
  value: string;
  onChange: (value: string) => void;
}) => {
  const { setOpen } = useSidebar();

  const options = useMemo(() => {
    return {
      minHeight: "550px", //editorの最小高さ
      autoFocus: true, // フォーカスを自動で当てる
      spellChecker: false, // スペルチェックを無効化
      sideBySideFullscreen: false, // 全画面表示にならずに並べて編集
      unorderedListStyle: "-", // リストのスタイルを変更
      toolbarTips: true, // ツールバーのヒントを非表示
       toolbar: [
        "bold",
        "italic",
        "heading",
        "|",
        "unordered-list",
        "|",
        "preview",
        "side-by-side",
        "fullscreen",
      ],
      shortcuts: {
        toggleBold: "Cmd-B",
        toggleItalic: "Cmd-I",
        toggleHeadingSmaller: "Cmd-H",
        toggleUnorderedList: "Cmd-U",
        toggleFullScreen: "Cmd-J",
        togglePreview: "Cmd-P",
        toggleSideBySide: "Cmd-Y",
      },
      onToggleFullScreen: (isFullScreen: boolean) => {
        // 全画面表示を切り替えるときにサイドバーを非表示にする
        if (isFullScreen) {
          setOpen(true);
        } else {
          setOpen(false);
        }
      },
   } as SimpleMDEReactProps["options"];
  }, []);

  return <SimpleMDE value={value} onChange={onChange} options={options} />;
};

公式も述べていますが、useMemoで囲んで、それをSimpleMDEにpropsとして渡しましょう。

でだ。色々optionがすでに設定されていますが、本章の対象はshortcutです。

詳しい方は気づくかと思いますが、このままだと動きません。

なんとなく Cmdとbを押したら太字になりそうですよね。

はい、罠です。

このb、大文字じゃないとダメです。

公式にもよく見ると書いてありました。

github.com

ちなみにsidebarというのはshadcnのsidebarです笑

全画面表示にしちゃうとsidebarが見えている時にeditorが背中にいっちゃって気持ち悪いので連動して動かすようにしています。

デフォルトのコマンドショートカットが邪魔してうざい

      shortcuts: {
        //コマンドショートカットキーを上書きたいもの
        toggleBold: "Cmd-B",
        toggleItalic: "Cmd-I",
        toggleHeadingSmaller: "Cmd-H",
        toggleUnorderedList: "Cmd-U",
        toggleFullScreen: "Cmd-J",
        togglePreview: "Cmd-P",
        toggleSideBySide: "Cmd-Y",
      },

実はこの設定だと、隠しコマンドができちゃいます。

例えば Cmd + K だと とかいう表示が差し込まれちゃったり。

これは shortcutsは drawlinkというものが対応しているのですが、やっぱりtoolbarに表示させているものしか

できないようにさせたいですよね。

なのでそれを無効化します。

調べると nullを設定すると良いそうです。

ということでそれをやるとこんな感じです。

// https://github.com/Ionaru/easy-markdown-editor?tab=readme-ov-file#keyboard-shortcuts
const DEFAULT_ALL_ACTIONS = [
  "toggleBlockquote",
  "toggleBold",
  "cleanBlock",
  "toggleHeadingSmaller",
  "toggleItalic",
  "drawLink",
  "toggleUnorderedList",
  "togglePreview",
  "toggleCodeBlock",
  "drawImage",
  "toggleOrderedList",
  "toggleHeadingBigger",
  "toggleSideBySide",
  "toggleFullScreen",
  "toggleHeading1",
  "toggleHeading2",
  "toggleHeading3",
  "toggleHeading4",
  "toggleHeading5",
  "toggleHeading6",
] as const;

const ALLOWED_ACTIONS_MAP: Map<string, string> = new Map([
  ["toggleBold", "Cmd-B"],
  ["toggleItalic", "Cmd-I"],
  ["toggleHeadingSmaller", "Cmd-H"],
  ["toggleUnorderedList", "Cmd-U"],
  ["toggleFullScreen", "Cmd-J"],
  ["togglePreview", "Cmd-P"],
  ["toggleSideBySide", "Cmd-Y"],
]);

export const ALLOWED_ACTIONS = DEFAULT_ALL_ACTIONS.reduce(
  (acc, cur) => {
    return {
      ...acc,
      [cur]: ALLOWED_ACTIONS_MAP.get(cur) ?? null,
    };
  },
  {} as Record<string, string | null>
);

これを先ほどのshortcutsに読み込みます。

      shortcuts: ALLOWED_ACTIONS,

めでたしめでたし。

次にはてなブログみたいにCmd + Sで更新したい。

これはextraKeysの出番です。

なんかこれを使うと、Editor自体に新しくショートカットキーのようなものを追加できるとありました。

さっきのshortcutとは何が違うのやら。

とりあえず以下のように設定します。

"use client";

import { useMemo } from "react";
import dynamic from "next/dynamic";
import "easymde/dist/easymde.min.css";
import { useSidebar } from "@/components/ui/sidebar";
import { SimpleMDEReactProps } from "react-simplemde-editor";
import { ALLOWED_ACTIONS } from "../editor/options";

const SimpleMDE = dynamic(() => import("react-simplemde-editor"), {
  ssr: false,
});

export const MarkdownEditor = ({
  value,
  onChange,
  onSave,
}: {
  value: string;
  onChange: (value: string) => void;
  onSave?: () => void;
}) => {
  const { setOpen } = useSidebar();

//これね
  const extraKeys = useMemo(() => {
    return {
      "Cmd-S": () => {
        onSave?.();
      },
    };
  }, []);
  const options = useMemo(() => {
    return {
      minHeight: "550px", //editorの最小高さ
      autoFocus: true, // フォーカスを自動で当てる
      spellChecker: false, // スペルチェックを無効化
      sideBySideFullscreen: false, // 全画面表示にならずに並べて編集
      unorderedListStyle: "-", // リストのスタイルを変更
      toolbarTips: true, // ツールバーのヒントを非表示
      toolbar: [
        // ツールバーに表示させるもの
        "bold",
        "italic",
        "heading",
        "|",
        "unordered-list",
        "|",
        "preview",
        "side-by-side",
        "fullscreen",
      ],
      shortcuts: ALLOWED_ACTIONS, // ショートカットキーを上書き
      onToggleFullScreen: (isFullScreen: boolean) => {
        // 全画面表示を切り替えるときにサイドバーを非表示にする
        if (isFullScreen) {
          setOpen(true);
        } else {
          setOpen(false);
        }
      },
    } as SimpleMDEReactProps["options"];
  }, []);

  return (
    <SimpleMDE
      value={value}
      onChange={onChange}
      options={options}
      extraKeys={extraKeys} <= ここ
    />
  );
};

ちなみにこの時のコマンドも大文字です。

これをして上から呼び出す時にpropsとして

                <MarkdownEditor
                  value={field.value ?? ""}
                  onChange={field.onChange}
                  onSave={form.handleSubmit(onSubmit)}
                />

として渡してあげればok.

あとは何かあるか。

見つかればその時はおいおいやっていきます。

個人的にはpreviewの画面をもう少しなんとかしたいですが、続きはまた今度で。

最終的なコード。

editor部分

"use client";

import { useMemo } from "react";
import dynamic from "next/dynamic";
import "easymde/dist/easymde.min.css";
import { useSidebar } from "@/components/ui/sidebar";
import { SimpleMDEReactProps } from "react-simplemde-editor";
import { ALLOWED_ACTIONS } from "../editor/options";

const SimpleMDE = dynamic(() => import("react-simplemde-editor"), {
  ssr: false,
});

export const MarkdownEditor = ({
  value,
  onChange,
  onSave,
}: {
  value: string;
  onChange: (value: string) => void;
  onSave?: () => void;
}) => {
  const { setOpen } = useSidebar();

  const extraKeys = useMemo(() => {
    return {
      "Cmd-S": () => {
        onSave?.();
      },
    };
  }, []);

  const options = useMemo(() => {
    return {
      minHeight: "550px", //editorの最小高さ
      autoFocus: true, // フォーカスを自動で当てる
      spellChecker: false, // スペルチェックを無効化
      sideBySideFullscreen: false, // 全画面表示にならずに並べて編集
      unorderedListStyle: "-", // リストのスタイルを変更
      toolbarTips: true, // ツールバーのヒントを非表示
      toolbar: [
        // ツールバーに表示させるもの
        "bold",
        "italic",
        "heading",
        "|",
        "unordered-list",
        "|",
        "preview",
        "side-by-side",
        "fullscreen",
      ],
      shortcuts: ALLOWED_ACTIONS, // ショートカットキーを上書き
      onToggleFullScreen: (isFullScreen: boolean) => {
        // 全画面表示を切り替えるときにサイドバーを非表示にする
        if (isFullScreen) {
          setOpen(true);
        } else {
          setOpen(false);
        }
      },
    } as SimpleMDEReactProps["options"];
  }, []);

  return (
    <SimpleMDE
      value={value}
      onChange={onChange}
      options={options}
      extraKeys={extraKeys}
    />
  );
};

form部分(createですがupdateも似たようなもんです)

"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { createItem } from "@/lib/items/actions";
import { ItemCreateForm, itemCreateSchema } from "@/schema/item-schema";

import { MarkdownEditor } from "./markdown-editor";

export const CreateItemForm = () => {
  const router = useRouter();
  const form = useForm<ItemCreateForm>({
    resolver: zodResolver(itemCreateSchema),
    defaultValues: {
      title: "",
      content: "",
    },
  });

  const onSubmit = async (data: ItemCreateForm) => {
    try {
      const item = await createItem(data);
      toast.success("Successfully created item");
      router.push(`/items/${item.id}`);
    } catch (error) {
      toast.error("Failed to create item");
      console.error(error);
    }
  };

  return (
    <Form {...form}>
      <form
        onSubmit={form.handleSubmit(onSubmit)}
        className="flex flex-col w-full"
      >
        <div className="flex gap-4">
          <FormField
            control={form.control}
            name="title"
            render={({ field }) => (
              <FormItem className="grow mb-4">
                <FormControl>
                  <Input
                    type="text"
                    placeholder="Box Name"
                    className="text-3xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-4xl outline-none bg-transparent w-fit min-w-[300px]"
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <Button
            type="submit"
            className="cursor-pointer"
            disabled={form.formState.isSubmitting}
          >
            {form.formState.isSubmitting ? "Creating..." : "Create"}
          </Button>
        </div>
        <FormField
          control={form.control}
          name="content"
          render={({ field }) => (
            <FormItem>
              <FormControl>
                <MarkdownEditor
                  value={field.value ?? ""}
                  onChange={field.onChange}
                  onSave={form.handleSubmit(onSubmit)}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
      </form>
    </Form>
  );
};

その上のpage.tsxについてはそのままですね。

終わりに

ということで色々いじってみました。

これで大体できたと思いますが、保存した時にラグがあるのが少し気がかりですね。

気が向いた時に直していこうと思います。