Skip to content

Commit bffd98f

Browse files
authored
feat(richtext-lexical): lexical => JSX converter (#8795)
Example: ```tsx import React from 'react' import { type JSXConvertersFunction, RichText, } from '@payloadcms/richtext-lexical/react' const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({ ...defaultConverters, blocks: { // myTextBlock is the slug of the block myTextBlock: ({ node }) => <div style={{ backgroundColor: 'red' }}>{node.fields.text}</div>, }, }) export const MyComponent = ({ lexicalContent }) => { return ( <RichText converters={jsxConverters} data={data.lexicalWithBlocks as SerializedEditorState} /> ) } ```
1 parent 601759d commit bffd98f

File tree

21 files changed

+798
-13
lines changed

21 files changed

+798
-13
lines changed

docs/lexical/converters.mdx

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,67 @@ desc: Conversion between lexical, markdown and html
66
keywords: lexical, rich text, editor, headless cms, convert, html, mdx, markdown, md, conversion, export
77
---
88

9+
Lexical saves data in JSON - this is great for storage and flexibility and allows you to easily to convert it to other formats like JSX, HTML or Markdown.
10+
11+
## Lexical => JSX
12+
13+
If you have a React-based frontend, converting lexical to JSX is the recommended way to render rich text content in your frontend. To do that, import the `RichText` component from `@payloadcms/richtext-lexical/react` and pass the lexical content to it:
14+
15+
```tsx
16+
import React from 'react'
17+
import { RichText } from '@payloadcms/richtext-lexical/react'
18+
19+
export const MyComponent = ({ lexicalData }) => {
20+
return (
21+
<RichText data={lexicalData} />
22+
)
23+
}
24+
```
25+
26+
The `RichText` component will come with the most common serializers built-in, though you can also pass in your own serializers if you need to.
27+
28+
<Banner type="default">
29+
The JSX converter expects the input data to be fully populated. When fetching data, ensure the `depth` setting is high enough, to ensure that lexical nodes such as uploads are populated.
30+
</Banner>
31+
32+
### Converting Lexical Blocks to JSX
33+
34+
In order to convert lexical blocks or inline blocks to JSX, you will have to pass the converter for your block to the RichText component. This converter is not included by default, as Payload doesn't know how to render your custom blocks.
35+
36+
```tsx
37+
import React from 'react'
38+
import {
39+
type JSXConvertersFunction,
40+
RichText,
41+
} from '@payloadcms/richtext-lexical/react'
42+
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
43+
44+
const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
45+
...defaultConverters,
46+
blocks: {
47+
// myTextBlock is the slug of the block
48+
myTextBlock: ({ node }) => <div style={{ backgroundColor: 'red' }}>{node.fields.text}</div>,
49+
},
50+
})
51+
52+
export const MyComponent = ({ lexicalData }) => {
53+
return (
54+
<RichText
55+
converters={jsxConverters}
56+
data={lexicalData.lexicalWithBlocks as SerializedEditorState}
57+
/>
58+
)
59+
}
60+
```
61+
962
## Lexical => HTML
1063

11-
Lexical saves data in JSON, but can also generate its HTML representation via two main methods:
64+
If you don't have a React-based frontend, or if you need to send the content to a third-party service, you can convert lexical to HTML. There are two ways to do this:
1265

1366
1. **Outputting HTML from the Collection:** Create a new field in your collection to convert saved JSON content to HTML. Payload generates and outputs the HTML for use in your frontend.
1467
2. **Generating HTML on any server** Convert JSON to HTML on-demand on the server.
1568

16-
The editor comes with built-in HTML serializers, simplifying the process of converting JSON to HTML.
69+
In both cases, the conversion needs to happen on a server, as the HTML converter will automatically fetch data for nodes that require it (e.g. uploads and internal links). The editor comes with built-in HTML serializers, simplifying the process of converting JSON to HTML.
1770

1871
### Outputting HTML from the Collection
1972

packages/richtext-lexical/package.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@
3030
"types": "./src/exports/client/index.ts",
3131
"default": "./src/exports/client/index.ts"
3232
},
33+
"./react": {
34+
"import": "./src/exports/react/index.ts",
35+
"types": "./src/exports/react/index.ts",
36+
"default": "./src/exports/react/index.ts"
37+
},
3338
"./rsc": {
3439
"import": "./src/exports/server/rsc.ts",
3540
"types": "./src/exports/server/rsc.ts",
@@ -355,7 +360,7 @@
355360
"mdast-util-from-markdown": "2.0.2",
356361
"mdast-util-mdx-jsx": "3.1.3",
357362
"micromark-extension-mdx-jsx": "3.0.1",
358-
"react-error-boundary": "4.0.13",
363+
"react-error-boundary": "4.1.1",
359364
"ts-essentials": "10.0.3",
360365
"uuid": "10.0.0"
361366
},
@@ -413,6 +418,11 @@
413418
"types": "./dist/exports/client/index.d.ts",
414419
"default": "./dist/exports/client/index.js"
415420
},
421+
"./react": {
422+
"import": "./dist/exports/react/index.js",
423+
"types": "./dist/exports/react/index.d.ts",
424+
"default": "./dist/exports/react/index.js"
425+
},
416426
"./rsc": {
417427
"import": "./dist/exports/server/rsc.js",
418428
"types": "./dist/exports/server/rsc.d.ts",
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { SerializedQuoteNode } from '../../../../../../nodeTypes.js'
2+
import type { JSXConverters } from '../types.js'
3+
4+
export const BlockquoteJSXConverter: JSXConverters<SerializedQuoteNode> = {
5+
quote: ({ node, nodesToJSX }) => {
6+
const children = nodesToJSX({
7+
nodes: node.children,
8+
})
9+
10+
return <blockquote>{children}</blockquote>
11+
},
12+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { SerializedHeadingNode } from '../../../../../../nodeTypes.js'
2+
import type { JSXConverters } from '../types.js'
3+
4+
export const HeadingJSXConverter: JSXConverters<SerializedHeadingNode> = {
5+
heading: ({ node, nodesToJSX }) => {
6+
const children = nodesToJSX({
7+
nodes: node.children,
8+
})
9+
10+
const NodeTag = node.tag
11+
12+
return <NodeTag>{children}</NodeTag>
13+
},
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { SerializedHorizontalRuleNode } from '../../../../../../nodeTypes.js'
2+
import type { JSXConverters } from '../types.js'
3+
export const HorizontalRuleJSXConverter: JSXConverters<SerializedHorizontalRuleNode> = {
4+
horizontalrule: () => {
5+
return <hr />
6+
},
7+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { SerializedLineBreakNode } from '../../../../../../nodeTypes.js'
2+
import type { JSXConverters } from '../types.js'
3+
4+
export const LinebreakJSXConverter: JSXConverters<SerializedLineBreakNode> = {
5+
linebreak: () => {
6+
return <br />
7+
},
8+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { SerializedAutoLinkNode, SerializedLinkNode } from '../../../../../../nodeTypes.js'
2+
import type { JSXConverters } from '../types.js'
3+
4+
export const LinkJSXConverter: (args: {
5+
internalDocToHref?: (args: { linkNode: SerializedLinkNode }) => string
6+
}) => JSXConverters<SerializedAutoLinkNode | SerializedLinkNode> = ({ internalDocToHref }) => ({
7+
autolink: ({ node, nodesToJSX }) => {
8+
const children = nodesToJSX({
9+
nodes: node.children,
10+
})
11+
12+
const rel: string | undefined = node.fields.newTab ? 'noopener noreferrer' : undefined
13+
const target: string | undefined = node.fields.newTab ? '_blank' : undefined
14+
15+
return (
16+
<a href={node.fields.url} {...{ rel, target }}>
17+
{children}
18+
</a>
19+
)
20+
},
21+
link: ({ node, nodesToJSX }) => {
22+
const children = nodesToJSX({
23+
nodes: node.children,
24+
})
25+
26+
const rel: string | undefined = node.fields.newTab ? 'noopener noreferrer' : undefined
27+
const target: string | undefined = node.fields.newTab ? '_blank' : undefined
28+
29+
let href: string = node.fields.url
30+
if (node.fields.linkType === 'internal') {
31+
if (internalDocToHref) {
32+
href = internalDocToHref({ linkNode: node })
33+
} else {
34+
console.error(
35+
'Lexical => JSX converter: Link converter: found internal link, but internalDocToHref is not provided',
36+
)
37+
href = '#' // fallback
38+
}
39+
}
40+
41+
return (
42+
<a href={href} {...{ rel, target }}>
43+
{children}
44+
</a>
45+
)
46+
},
47+
})
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { v4 as uuidv4 } from 'uuid'
2+
3+
import type { SerializedListItemNode, SerializedListNode } from '../../../../../../nodeTypes.js'
4+
import type { JSXConverters } from '../types.js'
5+
6+
export const ListJSXConverter: JSXConverters<SerializedListItemNode | SerializedListNode> = {
7+
list: ({ node, nodesToJSX }) => {
8+
const children = nodesToJSX({
9+
nodes: node.children,
10+
})
11+
12+
const NodeTag = node.tag
13+
14+
return <NodeTag className={`list-${node?.listType}`}>{children}</NodeTag>
15+
},
16+
listitem: ({ node, nodesToJSX, parent }) => {
17+
const hasSubLists = node.children.some((child) => child.type === 'list')
18+
19+
const children = nodesToJSX({
20+
nodes: node.children,
21+
})
22+
23+
if ('listType' in parent && parent?.listType === 'check') {
24+
const uuid = uuidv4()
25+
26+
return (
27+
<li
28+
aria-checked={node.checked ? 'true' : 'false'}
29+
className={`list-item-checkbox${node.checked ? ' list-item-checkbox-checked' : ' list-item-checkbox-unchecked'}${hasSubLists ? ' nestedListItem' : ''}`}
30+
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
31+
role="checkbox"
32+
style={{ listStyleType: 'none' }}
33+
tabIndex={-1}
34+
value={node?.value}
35+
>
36+
{hasSubLists ? (
37+
children
38+
) : (
39+
<>
40+
<input checked={node.checked} id={uuid} type="checkbox" />
41+
<label htmlFor={uuid}>{children}</label>
42+
<br />
43+
</>
44+
)}
45+
</li>
46+
)
47+
} else {
48+
return (
49+
<li
50+
className={hasSubLists ? 'nestedListItem' : ''}
51+
style={hasSubLists ? { listStyleType: 'none' } : {}}
52+
value={node?.value}
53+
>
54+
{children}
55+
</li>
56+
)
57+
}
58+
},
59+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { SerializedParagraphNode } from '../../../../../../nodeTypes.js'
2+
import type { JSXConverters } from '../types.js'
3+
4+
export const ParagraphJSXConverter: JSXConverters<SerializedParagraphNode> = {
5+
paragraph: ({ node, nodesToJSX }) => {
6+
const children = nodesToJSX({
7+
nodes: node.children,
8+
})
9+
10+
if (!children?.length) {
11+
return (
12+
<p>
13+
<br />
14+
</p>
15+
)
16+
}
17+
18+
return <p>{children}</p>
19+
},
20+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type {
2+
SerializedTableCellNode,
3+
SerializedTableNode,
4+
SerializedTableRowNode,
5+
} from '../../../../../../nodeTypes.js'
6+
import type { JSXConverters } from '../types.js'
7+
8+
export const TableJSXConverter: JSXConverters<
9+
SerializedTableCellNode | SerializedTableNode | SerializedTableRowNode
10+
> = {
11+
table: ({ node, nodesToJSX }) => {
12+
const children = nodesToJSX({
13+
nodes: node.children,
14+
})
15+
return (
16+
<table className="lexical-table" style={{ borderCollapse: 'collapse' }}>
17+
<tbody>{children}</tbody>
18+
</table>
19+
)
20+
},
21+
tablecell: ({ node, nodesToJSX }) => {
22+
const children = nodesToJSX({
23+
nodes: node.children,
24+
})
25+
26+
const TagName = node.headerState > 0 ? 'th' : 'td' // Use capital letter to denote a component
27+
const headerStateClass = `lexical-table-cell-header-${node.headerState}`
28+
const style = {
29+
backgroundColor: node.backgroundColor || undefined, // Use undefined to avoid setting the style property if not needed
30+
border: '1px solid #ccc',
31+
padding: '8px',
32+
}
33+
34+
// Note: JSX does not support setting attributes directly as strings, so you must convert the colSpan and rowSpan to numbers
35+
const colSpan = node.colSpan && node.colSpan > 1 ? node.colSpan : undefined
36+
const rowSpan = node.rowSpan && node.rowSpan > 1 ? node.rowSpan : undefined
37+
38+
return (
39+
<TagName
40+
className={`lexical-table-cell ${headerStateClass}`}
41+
colSpan={colSpan} // colSpan and rowSpan will only be added if they are not null
42+
rowSpan={rowSpan}
43+
style={style}
44+
>
45+
{children}
46+
</TagName>
47+
)
48+
},
49+
tablerow: ({ node, nodesToJSX }) => {
50+
const children = nodesToJSX({
51+
nodes: node.children,
52+
})
53+
return <tr className="lexical-table-row">{children}</tr>
54+
},
55+
}

0 commit comments

Comments
 (0)