Using Excalidraw in PayloadCMS

veiag.dev

Introduction
Recently, I explored integrating Excalidraw — a powerful open-source whiteboard tool — within the PayloadCMS Lexical editor. Fortunately, the official Lexical Playground site already includes Excalidraw as a built-in feature. Inspired by this, I tried to replicate this functionality using Payload's block system to seamlessly embed and edit Excalidraw drawings like this:
In this post, I'll explain how I implemented the Excalidraw block in PayloadCMS, which allows me to create, save, and edit drawings directly from the admin panel.
Implementing the Excalidraw Block
1. Defining the Block Schema
The first step is to define the ExcalidrawBlock in Payload. This block includes:
code
(JSON) – Stores Excalidraw elements.svg
(Textarea) – Stores the SVG representation of the drawing.height
(Select) – Controls the height of the drawing (Additional field to control SVG sizing on the front end ).
1import { Block } from 'payload'
2
3export const ExcalidrawBlock: Block = {
4 slug: 'excalidraw',
5 fields: [
6 {
7 name: 'code',
8 type: 'json',
9 label: 'Code',
10 },
11 {
12 name: 'svg',
13 type: 'textarea',
14 maxLength: 999999,
15 },
16 {
17 // additional field, to control image sizing
18 type: 'select',
19 name: 'height',
20 label: 'Height',
21 options: [
22 {
23 label: 'Default',
24 value: 'default',
25 },
26 {
27 label: 'Square',
28 value: 'square',
29 },
30 {
31 label: 'Unrestricted',
32 value: 'unrestricted',
33 },
34 ],
35 defaultValue: 'default',
36 },
37 ],
38}
39
2. Building the Excalidraw Field Component
The ExcalidrawBlock
component handles rendering the drawing, opening the Excalidraw editor in a modal imported from @payload/ui
, and saving both the SVG and JSON data.
1//Excalidraw.tsx
2'use client'
3
4import { Button, Modal, useField, useForm, useFormFields, useModal } from '@payloadcms/ui'
5import dynamic from 'next/dynamic'
6import { JSONFieldClientProps } from 'payload'
7import './index.scss'
8import { useMemo } from 'react'
9
10// Since client components get prerenderd on server as well hence importing
11// the excalidraw stuff dynamically with ssr false
12const ExcalidrawWrapper = dynamic(() => import('@/fields/Excalidraw/ExcalidrawWrapper'), {
13 ssr: false,
14})
15
16const ExcalidrawBlock: React.FC<JSONFieldClientProps> = ({ field, path }) => {
17 const { toggleModal, closeModal } = useModal()
18
19 const { dispatchFields, fields } = useForm()
20 const { value, setValue } = useField<string>({ path: path || field.name })
21
22 const customID = useMemo(() => {
23 return fields?.['id']?.value as string
24 }, [fields])
25 const jsonFieldPath = path?.includes('.') ? `${path}.${'code'}` : 'code'
26
27
28 const json = useFormFields(([fields]) => {
29 return fields['code']?.value as string
30 })
31
32 const handleSave = (svg: string, elements: string) => {
33 closeModal(`excalidraw-${customID}`)
34
35 dispatchFields({
36 type: 'UPDATE',
37 path: jsonFieldPath,
38 value: elements,
39 })
40
41 setValue(svg)
42 }
43
44 return (
45 <div className="excalidraw-block">
46 <div dangerouslySetInnerHTML={{ __html: value }} className="excalidraw-svg"></div>
47 <Button onClick={() => toggleModal(`excalidraw-${customID}`)} className="excalidraw-edit">
48 Open Excalidraw
49 </Button>
50 <Modal slug={`excalidraw-${customID}`} className="excalidraw-modal">
51 <ExcalidrawWrapper
52 onSave={handleSave}
53 initialElements={json}
54 closeModal={() => closeModal(`excalidraw-${customID}`)}
55 />
56 </Modal>
57 </div>
58 )
59}
And ExcalidrawWrapper
component integrates the Excalidraw editor and handles exporting the drawing to SVG. Also, it renders additional buttons right in Excalidraw UI, to close the dialog and save changes.
1'use client'
2import { Excalidraw, exportToSvg } from '@excalidraw/excalidraw'
3
4import '@excalidraw/excalidraw/index.css'
5import { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
6import { Button } from '@payloadcms/ui'
7import { useState } from 'react'
8type ExcalidrawWrapperProps = {
9 onSave: (svg: string, elements: string) => void
10 initialElements?: string
11 closeModal: () => void
12}
13const ExcalidrawWrapper: React.FC<ExcalidrawWrapperProps> = ({
14 onSave,
15 initialElements,
16 closeModal,
17}) => {
18 const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI>()
19
20 const handleExportToSVG = async () => {
21 if (!excalidrawAPI) {
22 return
23 }
24 const elements = excalidrawAPI.getSceneElements()
25 if (!elements || !elements.length) {
26 return
27 }
28 const svg = await exportToSvg({
29 elements,
30 appState: {
31 ...excalidrawAPI.getAppState(),
32 exportWithDarkMode: true,
33 exportBackground: false,
34 },
35 files: excalidrawAPI.getFiles(),
36 })
37
38 //save svg to json field and save current elements , which allows us to edit image later
39 onSave(svg.outerHTML, JSON.stringify(elements))
40 }
41 return (
42 <div className="excalidraw-wrapper">
43 <Excalidraw
44 theme="dark"
45 excalidrawAPI={(api) => setExcalidrawAPI(api)}
46 initialData={{
47 elements: JSON.parse(initialElements || '[]'),
48 }}
49 renderTopRightUI={() => {
50 return (
51 <>
52 <Button
53 buttonStyle="secondary"
54 onClick={() => {
55 closeModal()
56 }}
57 className="remove-margins"
58 >
59 Exit
60 </Button>
61 <Button
62 buttonStyle="primary"
63 onClick={() => {
64 handleExportToSVG()
65 }}
66 className="remove-margins"
67 >
68 Save
69 </Button>
70 </>
71 )
72 }}
73 ></Excalidraw>
74 </div>
75 )
76}
77export default ExcalidrawWrapper
78
We need to import this component dynamicly, since Excalidraw doesn't support server-side rendering. Excalidraw docs. Additionally, i created css fix for excalidraw own modals, because they are absolute positioned , and not visible when you scroll down.
1.excalidraw .Modal{
2 position: fixed !important; // fix for excalidraw modals , showing at top of page , not in viewport
3}
4
Then , i used ExcalidrawBlock
component as a field component in Admin:
1...
2{
3 name: 'svg',
4 type: 'textarea',
5 maxLength: 999999,
6 admin: {
7 components: {
8 Field: './fields/Excalidraw',
9 },
10 },
11}
12...
3. Rendering on client
To properly render the Excalidraw block on the client, I created a custom jsxConverter
for the RichText
component. This allows the rendering of SVG drawings dynamically based on the block configuration.
1type NodeTypes = DefaultNodeTypes | SerializedBlockNode<Excalidraw>
2
3const jsxConverters: JSXConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({
4 ...defaultConverters,
5 blocks: {
6 excalidraw: ({ node }) => {
7 let className = ''
8 switch (node.fields.height) {
9 case 'default':
10 className = '*:max-h-[360px]' //This also provided in styles for compatibility ( old ios sucks )
11 break
12 case 'square':
13 className = '*:aspect-square'
14 break
15 case 'unrestricted':
16 className = ''
17 break
18 }
19 return (
20 <div
21 dangerouslySetInnerHTML={{ __html: node.fields.svg || '' }}
22 className={cn(
23 'w-full h-auto *:w-full *:h-auto mt-4 mb-8',
24 className,
25 `exclaidraw-${node.fields.height}`,
26 )}
27 />
28 )
29 },
30 }
31})
Possible Improvements
One area for improvement is optimizing the API responses. Currently, the code
field (which holds the Excalidraw elements) is included in the responses. This increases the payload size and slows down the site. Although I attempted to hide this field using hidden: true
, it became inaccessible in the Excalidraw field component.
Additionally, storing SVG as a string in the database is not the most efficient approach. However, I currently lack the advanced Payload knowledge required to implement a more optimized solution.
Another potential improvement involves avoiding dangerouslySetInnerHTML
for rendering SVGs. While this method works fine in my case, it's not safe to do so.
Conclusion
By leveraging Payload's block feature and integrating Excalidraw, you can provide a rich, interactive drawing experience within your CMS. Users can embed, edit, and export drawings without leaving the admin panel.