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
TypographyStylesProviderbecause 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
editableandnot-editableclass - To improve ssr i render the results plainly in server side using
editorjs-parser
"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;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;
thank you for this