Skip to content

Instantly share code, notes, and snippets.

@Thisisjuke
Last active February 11, 2025 02:16
Show Gist options
  • Save Thisisjuke/46d9c0b1b6fbae040931c6897a9fb034 to your computer and use it in GitHub Desktop.
Save Thisisjuke/46d9c0b1b6fbae040931c6897a9fb034 to your computer and use it in GitHub Desktop.
EditorJS component to easily create custom Blocks/Tool with Typescript and React (usable in NextJS)
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import type { OutputData } from '@editorjs/editorjs'
import Editor from './Editor'
const meta: Meta<typeof Editor> = {
title: 'Components/Editor',
component: Editor,
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof Editor>
export const Default: Story = {
render: (args) => {
const [data, setData] = useState<OutputData>()
return (
<Editor {...args} data={data} onChange={setData} />
)
},
args: {
holder: 'editorjs-container',
},
}
import React, { useEffect, useRef } from 'react'
import type { OutputData, ToolConstructable } from '@editorjs/editorjs'
import EditorJS from '@editorjs/editorjs'
import QuestionTool from '@/modules/Editor/tools/QuestionTool/TextsQuestionTool'
import TitleTool from '@/modules/Editor/tools/TitleTool/TitleTool'
import DisplayImageTool from '@/modules/Editor/tools/DisplayImageTool/DisplayImageTool'
const tools: { [toolName: string]: ToolConstructable } = {
titleTool: TitleTool as unknown as ToolConstructable,
displayImageTool: DisplayImageTool as unknown as ToolConstructable,
imagesQuestionTool: ImagesQuestionTool as unknown as ToolConstructable,
textsQuestionTool: TextsQuestionTool as unknown as ToolConstructable,
}
export interface EditorBlockProps {
data?: OutputData
onChange(val: OutputData): void
holder: string
}
const EditorBlock = ({ data, onChange, holder }: EditorBlockProps) => {
const ref = useRef<EditorJS>()
useEffect(() => {
if (!ref.current) {
ref.current = new EditorJS({
holder,
tools,
data,
async onChange(api) {
const data = await api.saver.save()
onChange(data)
},
})
}
return () => {
if (ref.current && ref.current.destroy) {
ref.current.destroy()
}
}
}, [])
return <div id={holder} className={'!mb-0 bg-gray-100 h-full !p-2 overflow-x-auto'} />
}
export default EditorBlock
import ReactDOM from 'react-dom'
import type { API, BlockTool, ToolConfig } from '@editorjs/editorjs'
import React, { createElement } from 'react'
interface CustomToolOptions<TData extends Record<string, any>, TConfig extends Record<string, any>, TOpts extends Record<string, any>> {
data: TData
config: TConfig
api: API
readOnly: boolean
component: React.ComponentType<{ onDataChange: (newData: TData) => void; readOnly: boolean; data: TData; opts: TOpts }>
toolbox: ToolConfig
opts?: TOpts
}
export class CustomTool<TData extends Record<string, any>, TConfig extends Record<string, any>, TOpts extends Record<string, any>> implements BlockTool {
private api: API
private readonly readOnly: boolean
private data: TData
private config: TConfig
private component: React.ComponentType<{ onDataChange: (newData: TData) => void; readOnly: boolean; data: TData; options?: TOpts }>
private toolbox: ToolConfig
private readonly CSS = {
wrapper: 'custom-tool',
}
private nodes = {
holder: null as HTMLElement | null,
}
constructor(options: CustomToolOptions<TData, TConfig, TOpts>) {
const { data, config, api, readOnly, component, toolbox } = options
this.api = api
this.readOnly = readOnly
this.data = data
this.config = config
this.component = component as any
this.toolbox = toolbox
}
static get isReadOnlySupported(): boolean {
return true
}
render(): HTMLElement {
const rootNode = document.createElement('div')
rootNode.setAttribute('class', this.CSS.wrapper)
this.nodes.holder = rootNode
const onDataChange = (newData: TData) => {
this.data = {
...newData,
}
}
ReactDOM.render(<this.component onDataChange={onDataChange} readOnly={this.readOnly} data={this.data} />, rootNode)
return this.nodes.holder
}
save(): TData {
return this.data
}
static createTool<TData extends Record<string, any>, TConfig extends Record<string, any>, TOpts extends Record<string, any>>(
component: React.ComponentType<{ onDataChange: (newData: TData) => void; readOnly: boolean; data: TData; opts: TOpts }>,
toolbox: ToolConfig,
opts?: TOpts,
): new (options: CustomToolOptions<TData, TConfig, TOpts>) => CustomTool<TData, TConfig, TOpts> {
return class extends CustomTool<TData, TConfig, TOpts> {
constructor(options: CustomToolOptions<TData, TConfig, TOpts>) {
super({
...options,
component: (props: any) => createElement(component, { ...props, options: opts }),
toolbox,
data: {
events: [],
...options.data,
},
})
}
static get toolbox() {
return toolbox
}
}
}
}
import React from 'react'
export interface TitleInputProps {
data: Record<string, string>
onDataChange: (arg: Record<string, string>) => void
readOnly?: boolean
}
export const TitleInput = ({ data, onDataChange, readOnly }: TitleInputProps) => {
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const newData = {
title: event.target.value,
}
onDataChange(newData)
}
return (
<div className={'flex gap-x-2 w-full'}>
<span className={'text-xl underline shrink-0'}>Titre :</span>
<textarea
className={'bg-transparent border-0 focus:outline-none text-xl grow-1 w-full'}
defaultValue={data.title}
onChange={handleChange}
readOnly={readOnly}
/>
</div>
)
}
import { CustomTool } from '@/modules/Editor/tools/GenericTool'
import { TitleInput } from '@/modules/Editor/tools/TitleTool/TitleInput'
const TitleTool = CustomTool.createTool(
// ⬇️ Here is the component that will be used as the custom "tool" / "block" inside EditorJS.
TitleInput,
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m16 10l3-1v10M3 5v7m0 0v7m0-7h8m0-7v7m0 0v7"/></svg>',
title: 'Title',
},
)
export default TitleTool
@Thisisjuke
Copy link
Author

@MuhammadAhmed-Developer

TextsQuestionTool example:

import { QuestionInput } from '@/components/inputs/QuestionInput'
import { CustomTool } from '@/components/tools/GenericTool'

const TextsQuestionTool = CustomTool.createTool(
    QuestionInput,
    {
        icon: '(?)',
        title: 'Ask Question',
    },
    {
        type: 'texts',
    },
)

export default TextsQuestionTool

QuestionInput interface looks like this:

export interface QuestionInputProps {
    data: QuestionToolData
    onDataChange: (questionData: QuestionToolData) => void
    readOnly?: boolean
    options?: {
        type: 'images' | 'texts'
        [key: string]: unknown
    }
}

Added like this in the tool array:

import type { OutputData, ToolConstructable } from '@editorjs/editorjs'
import EditorJS from '@editorjs/editorjs'

const tools: { [toolName: string]: ToolConstructable } = {
    titleTool: TitleTool as unknown as ToolConstructable,
    textsQuestionTool: TextsQuestionTool as unknown as ToolConstructable,
}

Remember, tool is called inside the EditorJs instance:

ref.current = new EditorJS({
    holder,
    tools, // <== here
    data,
    async onChange(api) {
        const data = await api.saver.save()
        onChange(data)
    },
})

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