Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save Dadangdut33/eefecb29568b03b540b0d437ae146db9 to your computer and use it in GitHub Desktop.
Save Dadangdut33/eefecb29568b03b540b0d437ae146db9 to your computer and use it in GitHub Desktop.
Editor js with dark mode and mantine support

Here is how i managed to use and style editorjs for my use.

Some notes:

  • To obtain the content we pass setRef, so a higher component can get the content easily
  • You might not need to style the .dark style yourself if you are using mantine's TypographyStylesProvider because it should do the color just fine. (i styled it in my css because i forgot that it exists)
  • I use this component for both editing and rendering results, that's why there is editable and not-editable class
  • To improve ssr i render the results plainly in server side using editorjs-parser

Render the editor js component

"use client";

import { uploadStuff } from "@/lib/actions/upload";
import { parseJSONForEditor } from "@/lib/utilsClient";
import EditorJS, { OutputData } from "@editorjs/editorjs";
import { Paper, Skeleton, TypographyStylesProvider, useMantineColorScheme } from "@mantine/core";
import { Dispatch, MutableRefObject, SetStateAction, useCallback, useEffect, useRef, useState } from "react";

const SkeletonGimmick = () => (
  <>
    <Skeleton height={20} width={"100%"} radius="xl" mb={"md"} />
    <Skeleton height={20} width={"100%"} radius="xl" mb={"md"} />
    <Skeleton height={20} width={"100%"} radius="xl" mb={"md"} />
    <Skeleton height={20} width={"100%"} radius="xl" mb={"md"} />
  </>
);

const Editor = ({
  editable,
  setRef,
  dirtyCount,
  setDirtyCount,
  content,
  withWrapper = true,
}: {
  editable: boolean;
  setRef?: Dispatch<SetStateAction<MutableRefObject<EditorJS | undefined> | undefined>>;
  dirtyCount?: number;
  setDirtyCount?: Dispatch<SetStateAction<number>>;
  content?: string;
  withWrapper?: boolean;
}) => {
  const ref = useRef<EditorJS>();
  const [isMounted, setIsMounted] = useState<boolean>(false);
  const [initialized, setInitialized] = useState<boolean>(false);
  const { colorScheme } = useMantineColorScheme();

  const initializeEditor = useCallback(
    async (
      data: OutputData,
      readOnly = false,
      dirtyCount: number | undefined,
      setDirtyCount: Dispatch<SetStateAction<number>> | undefined
    ) => {
      const EditorJS = (await import("@editorjs/editorjs")).default;
      const Header = (await import("@editorjs/header")).default;
      const Embed = (await import("@editorjs/embed")).default;
      const Table = (await import("@editorjs/table")).default;
      const List = (await import("@editorjs/list")).default;
      const Code = (await import("@editorjs/code")).default;
      const LinkTool = (await import("@editorjs/link")).default;
      const InlineCode = (await import("@editorjs/inline-code")).default;
      const ImageTool = (await import("@editorjs/image")).default;
      const Delimiter = (await import("@editorjs/delimiter")).default;
      const Checklist = (await import("@editorjs/checklist")).default;
      const Marker = (await import("@editorjs/marker")).default;
      const AttachesTool = (await import("@editorjs/attaches")).default;
      const Underline = (await import("@editorjs/underline")).default;
      const TextVariantTune = (await import("@editorjs/text-variant-tune")).default;
      const Paragraph = (await import("@editorjs/paragraph")).default;
      const Warning = (await import("@editorjs/warning")).default;
      const VideoTool = (await import("@weekwood/editorjs-video")).default;
      if (!ref.current) {
        const editor = new EditorJS({
          holder: "editor",
          onReady() {
            ref.current = editor;
            // remove the first block because it likes to add an empty block for no reason
            if (editor.blocks.getBlockByIndex(0)?.isEmpty) {
              editor.blocks.delete(0);
              const whyIsThisSelected = document.getElementsByClassName("ce-block--selected");
              if (whyIsThisSelected.length) whyIsThisSelected[0].classList.remove("ce-block--selected");
            }
          },
          onChange: () => {
            if (setDirtyCount && dirtyCount !== undefined && dirtyCount < 5) setDirtyCount((prev) => prev + 1);
          },
          readOnly,
          inlineToolbar: true,
          data,
          tools: {
            header: {
              class: Header as any,
              config: {
                placeholder: "Enter a header",
                levels: [1, 2, 3, 4],
                defaultLevel: 2,
              },
            },
            linkTool: {
              class: LinkTool,
              config: {
                endpoint: `/api/fetch-link`,
              },
            },
            image: {
              class: ImageTool,
              config: {
                uploader: {
                  async uploadByFile(file: File) {
                    const formData = new FormData();
                    formData.append("file", file);
                    return await uploadStuff(formData, "IMAGE", "POST_IMAGE");
                  },
                  async uploadByUrl(url: string) {
                    const formData = new FormData();
                    formData.append("file", url);
                    return await uploadStuff(formData, "IMAGE", "POST_IMAGE");
                  },
                },
              },
            },
            video: {
              class: VideoTool,
              config: {
                uploader: {
                  async uploadByFile(file: File) {
                    const formData = new FormData();
                    formData.append("file", file);
                    return await uploadStuff(formData, "VIDEO", "POST_VIDEO");
                  },
                  async uploadByUrl(url: string) {
                    const formData = new FormData();
                    formData.append("file", url);
                    return await uploadStuff(formData, "VIDEO", "POST_VIDEO");
                  },
                },
                player: {
                  controls: true,
                  autoplay: false,
                },
              },
            },
            checklist: {
              class: Checklist,
              inlineToolbar: true,
            },
            attaches: {
              class: AttachesTool,
              config: {
                uploader: {
                  async uploadByFile(file: File) {
                    const formData = new FormData();
                    formData.append("file", file);
                    return await uploadStuff(formData, "FILE", "POST_FILE");
                  },
                },
              },
            },
            textVariant: TextVariantTune,
            paragraph: {
              class: Paragraph,
              tunes: ["textVariant"],
              inlineToolbar: true,
              config: {
                placeholder: editable ? "Write here..." : "",
                preserveBlank: true,
              },
            },
            warning: {
              class: Warning,
              inlineToolbar: true,
              config: {
                titlePlaceholder: "⚠️ Title",
                messagePlaceholder: "Message",
              },
            },
            underline: Underline,
            list: List,
            code: Code,
            InlineCode: InlineCode,
            table: Table,
            embed: Embed,
            delimiter: Delimiter,
            Marker: {
              class: Marker,
              shortcut: "CMD+SHIFT+M",
            },
          },
        });
      }
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    []
  );

  useEffect(() => {
    if (typeof window !== "undefined") setIsMounted(true);
  }, []);

  useEffect(() => {
    const init = async () => {
      await initializeEditor(parseJSONForEditor(content), !editable, dirtyCount, setDirtyCount);
      setInitialized(true);
      setTimeout(async () => {
        if (setRef) setRef(ref);
      }, 0);
    };

    if (isMounted) {
      init();

      return () => {
        ref.current?.destroy();
        ref.current = undefined;
      };
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isMounted, initializeEditor]);

  if (!isMounted) return <SkeletonGimmick />;

  if (withWrapper)
    return (
      <Paper shadow="md" p={"md"}>
        {initialized ? null : <SkeletonGimmick />}
        <TypographyStylesProvider>
          <div id="editor" className={colorScheme + ` ${editable ? "editable" : "not-editable"}`} />
        </TypographyStylesProvider>
      </Paper>
    );
  else
    return (
      <TypographyStylesProvider>
        {initialized ? null : <SkeletonGimmick />}
        <div id="editor" className={colorScheme + ` ${editable ? "editable" : "not-editable"}`} />
      </TypographyStylesProvider>
    );
};

export default Editor;

css style

style.css

/* ------------------------- */
/* Editor JS */
#editor {
  min-height: 200px;
}

.ce-block__content,
.ce-toolbar__content {
  max-width: calc(100% - 80px) !important;
}
.not-editable .ce-block__content,
.not-editable .ce-toolbar__content {
  max-width: 100% !important;
}

.codex-editor--narrow .codex-editor__redactor {
  margin-right: 0;
}

.cdx-block {
  max-width: 100% !important;
}

.codex-editor__redactor {
  padding-bottom: 0 !important;
}

/* image */
.embed-tool__caption,
.image-tool__caption {
  text-align: center;
  font-size: 14px;
}

.image-tool__image {
  display: flex;
}

.image-tool__image img {
  margin-right: auto;
  margin-left: auto;
  text-align: center;
}

.not-editable .embed-tool__caption,
.not-editable .image-tool__caption {
  border: none;
  padding-top: 0;
}

/* Warning */
.cdx-warning__title {
  font-size: 18px;
  font-weight: 600;
}

.cdx-warning__message {
  min-height: 0px !important;
}

.not-editable .cdx-warning__title,
.not-editable .cdx-warning__message {
  border: none;
  box-shadow: none;
  padding-top: 6px;
  padding-left: 8px;
}

.not-editable .cdx-warning__title {
  padding-bottom: 0;
}

.not-editable .cdx-warning__message {
  padding-top: 0;
}

.not-editable .cdx-warning {
  background-color: var(--mantine-color-gray-1);
  border-radius: 4px;
  border: 1px solid var(--mantine-color-gray-4);
}

.not-editable.dark .cdx-warning {
  background-color: var(--mantine-color-dark-6);
  border: 1px solid var(--mantine-color-dark-4);
}

.not-editable.dark .cdx-warning:before,
.not-editable  .cdx-warning:before {
  margin-left: .6rem;

}

.cdx-warning:before {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cline x1='12' y1='16' x2='12' y2='12'/%3E%3Cline x1='12' y1='8' x2='12' y2='8'/%3E%3C/svg%3E") !important;
}

.dark .cdx-warning:before {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cline x1='12' y1='16' x2='12' y2='12'/%3E%3Cline x1='12' y1='8' x2='12' y2='8'/%3E%3C/svg%3E") !important;
  /* background-image: url("data:image/svg+xml,%3Csvg%20width='24'%20height='24'%20viewBox='0%200%2024%2024'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3E%3Crect%20x='5'%20y='5'%20width='14'%20height='14'%20rx='4'%20stroke='white'%20stroke-width='2'/%3E%3Cline%20x1='12'%20y1='9'%20x2='12'%20y2='12'%20stroke='white'%20stroke-width='2'%20stroke-linecap='round'/%3E%3Cpath%20d='M12%2015.02V15.01'%20stroke='white'%20stroke-width='2'%20stroke-linecap='round'/%3E%3C/svg%3E"); */
}

/* dark mode editor js */
.dark .tc-add-row:before,
.dark div .video-tool__video,
.dark .ce-inline-toolbar,
.dark .codex-editor--narrow .ce-toolbox,
.dark .ce-conversion-toolbar,
.dark .ce-settings,
.dark .ce-settings__button,
.dark .ce-toolbar__settings-btn,
.dark .cdx-button,
.dark .ce-popover,
.dark .tc-popover--opened,
.dark .ce-toolbar__plus:hover {
  background-color: var(--mantine-color-dark-7);
  border-color: var(--mantine-color-dark-4);
  color: white;
}

.dark .ce-block--selected .ce-block__content {
  background-color: var(--mantine-color-dark-5);
}

/* table and the default popover */
.dark .tc-popover__item-icon,
.dark .ce-popover__item-icon,
.dark .ce-conversion-tool__icon {
  background-color: var(--mantine-color-gray-7);
  box-shadow: none;
}

.dark .ce-inline-tool,
.dark .ce-conversion-toolbar__label,
.dark .ce-toolbox__button,
.dark .cdx-settings-button,
.dark .ce-toolbar__plus {
  color: white;
}

.dark .ce-popover-item__title {
  color: white;
}

.dark .cdx-search-field {
  border-color: var(--mantine-color-gray-6);
  background-color: var(--mantine-color-gray-8);
  color: white;
}

.dark ::selection {
  background: var(--mantine-color-gray-7);
}

.dark .ce-popover-item:hover,
.dark .cdx-settings-button:hover,
.dark .ce-settings__button:hover,
.dark .ce-toolbox__button--active,
.dark .ce-toolbox__button:hover,
.dark .cdx-button:hover,
.dark .ce-inline-toolbar__dropdown:hover,
.dark .ce-inline-tool:hover,
.dark .ce-popover__item:hover,
.dark .ce-conversion-tool:hover,
.dark .ce-toolbar__settings-btn:hover {
  background-color: var(--mantine-color-gray-7);
}

.dark .cdx-notify--error {
  background: var(--mantine-color-gray-7) !important;
}

.dark .cdx-notify__cross::after,
.dark .cdx-notify__cross::before {
  background: white;
}

/* video */
div.cdx-block .video-tool__video {
  border: none;
  display: flex;
}

div.cdx-block .video-tool__video div {
  margin-left: auto;
  margin-right: auto;
}

/* table */
/* selector for last element of .tc-cell (remove the border right) */
.tc-cell:last-child {
  border-right: none;
}

.dark .tc-add-column:hover,
.dark .tc-add-row:hover,
.dark .tc-add-row:hover:before {
  background-color: var(--mantine-color-dark-5);
}

/* file attachment */
.dark .tc-cell--selected,
.dark .cdx-attaches {
  background-color: var(--mantine-color-dark-6);
  border-color: var(--mantine-color-dark-4);
}

.dark a.cdx-attaches__download-button {
  background-color: var(--mantine-color-dark-7);
}

.dark a.cdx-attaches__download-button:hover {
  background-color: var(--mantine-color-dark-5);
}

/* checkbox */
.dark .cdx-checklist__item-checkbox-check {
  background-color: var(--mantine-color-dark-5);
  border-color: var(--mantine-color-dark-4);
}

/* code */
.dark .ce-code__textarea {
  background-color: var(--mantine-color-dark-6);
  border-color: var(--mantine-color-dark-4);
  color: white;
  min-height: 50px;
}

.dark .cdx-text-variant__toggler {
  background-color: var(--mantine-color-dark-2);
  margin: .1rem
}

/* link tool */
.dark .link-tool__content {
  background-color: var(--mantine-color-dark-6);
  border-color: var(--mantine-color-dark-5);

}
/* alert */
.dark .cdx-alert {
  color: white;
  border-color: var(--mantine-color-dark-6);
}
.dark .cdx-alert-primary {
  background-color: var(--mantine-color-gray-6);
}
.dark .cdx-alert-secondary {
  background-color: #101878;
}
.dark .cdx-alert-info {
  background-color: var(--mantine-color-cyan-5);
}
.dark .cdx-alert-warning {
  background-color: var(--mantine-color-orange-8);
}
.dark .cdx-alert-danger {
  background-color: var(--mantine-color-red-8);
}
.dark .cdx-alert-success {
  background-color: var(--mantine-color-green-8);
}
.dark .cdx-alert-light {
  color: black;
}

/* typhology editor js */
h1.ce-header {
  font-size: 34px;
}

h2.ce-header {
  font-size: 26px;
}

h3.ce-header {
  font-size: 22px;
}

h4.ce-header {
  font-size: 18px;
}

ssr but hidden (you can actually use this for fully rendering the results if you want)

EditorJSSR.tsx

"use server";

import { parseJSONForEditor } from "@/lib/utilsClient";
import { OutputData } from "@editorjs/editorjs";
// @ts-ignore
import edjsParser from "editorjs-parser";

// default config
const config = {
  image: {
    use: "figure",
    // use figure or img tag for images (figcaption will be used for caption of figure)
    // if you use figure, caption will be visible
    imgClass: "img", // used class for img tags
    figureClass: "fig-img", // used class for figure tags
    figCapClass: "fig-cap", // used class for figcaption tags
    path: "absolute",
    // if absolute is passed, the url property which is the absolute path to the image will be used
    // otherwise pass a relative path with the filename property in <> like so: '/img/<fileName>'
  },
  paragraph: {
    pClass: "paragraph", // used class for paragraph tags
  },
  code: {
    codeBlockClass: "code-block", // used class for code blocks
  },
  embed: {
    useProvidedLength: false,
    // set to true if you want the returned width and height of editorjs to be applied
    // NOTE: sometimes source site overrides the lengths so it does not work 100%
  },
  quote: {
    applyAlignment: false,
    // if set to true blockquote element will have text-align css property set
  },
};

type VideoData = {
  file: {
    url: string;
  };
  caption: string;
  withBorder: boolean;
  stretched: boolean;
  withBackground: boolean;
};

type WarningData = {
  title: string;
  text: string;
};

type ChecklistData = {
  items: {
    text: string;
    checked: boolean;
  }[];
};

type AttachesData = {
  file: {
    url: string;
    title: string;
    extension: string;
    size: number;
  };
  title: string;
};

const customParsers = {
  video: function (data: VideoData, config: any) {
    return `<video src="${data.file.url}" controls></video>`;
  },
  warning: function (data: WarningData, config: any) {
    return `<h1>${data.title}</h1><p>${data.text}</p>`;
  },
  checklist: function (data: ChecklistData, config: any) {
    let list = "<ul>";
    for (const item of data.items) {
      list += `<li>${item.text}</li>`;
    }
    list += "</ul>";
    return list;
  },
  attaches: function (data: AttachesData, config: any) {
    return `<a href='${data.file.url}'>${data.file.title}${data.file.extension}</a>`;
  },
};

const EditorSSR = async ({ data }: { data: string }) => {
  const parsedData = parseJSONForEditor<OutputData>(data);
  const parser = new edjsParser(config, customParsers);
  const html = parser.parse(parsedData);

  return (
    <div style={{ display: "none" }}>
      <div dangerouslySetInnerHTML={{ __html: html }}></div>
    </div>
  );
};

export default EditorSSR;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment