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
andnot-editable
class - 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;