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}
/>
)
}
```
This commit is contained in:
@@ -6,14 +6,67 @@ desc: Conversion between lexical, markdown and html
|
|||||||
keywords: lexical, rich text, editor, headless cms, convert, html, mdx, markdown, md, conversion, export
|
keywords: lexical, rich text, editor, headless cms, convert, html, mdx, markdown, md, conversion, export
|
||||||
---
|
---
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Lexical => JSX
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React from 'react'
|
||||||
|
import { RichText } from '@payloadcms/richtext-lexical/react'
|
||||||
|
|
||||||
|
export const MyComponent = ({ lexicalData }) => {
|
||||||
|
return (
|
||||||
|
<RichText data={lexicalData} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<Banner type="default">
|
||||||
|
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.
|
||||||
|
</Banner>
|
||||||
|
|
||||||
|
### Converting Lexical Blocks to JSX
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
type JSXConvertersFunction,
|
||||||
|
RichText,
|
||||||
|
} from '@payloadcms/richtext-lexical/react'
|
||||||
|
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
|
||||||
|
|
||||||
|
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 = ({ lexicalData }) => {
|
||||||
|
return (
|
||||||
|
<RichText
|
||||||
|
converters={jsxConverters}
|
||||||
|
data={lexicalData.lexicalWithBlocks as SerializedEditorState}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Lexical => HTML
|
## Lexical => HTML
|
||||||
|
|
||||||
Lexical saves data in JSON, but can also generate its HTML representation via two main methods:
|
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:
|
||||||
|
|
||||||
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.
|
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.
|
||||||
2. **Generating HTML on any server** Convert JSON to HTML on-demand on the server.
|
2. **Generating HTML on any server** Convert JSON to HTML on-demand on the server.
|
||||||
|
|
||||||
The editor comes with built-in HTML serializers, simplifying the process of converting JSON to HTML.
|
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.
|
||||||
|
|
||||||
### Outputting HTML from the Collection
|
### Outputting HTML from the Collection
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,11 @@
|
|||||||
"types": "./src/exports/client/index.ts",
|
"types": "./src/exports/client/index.ts",
|
||||||
"default": "./src/exports/client/index.ts"
|
"default": "./src/exports/client/index.ts"
|
||||||
},
|
},
|
||||||
|
"./react": {
|
||||||
|
"import": "./src/exports/react/index.ts",
|
||||||
|
"types": "./src/exports/react/index.ts",
|
||||||
|
"default": "./src/exports/react/index.ts"
|
||||||
|
},
|
||||||
"./rsc": {
|
"./rsc": {
|
||||||
"import": "./src/exports/server/rsc.ts",
|
"import": "./src/exports/server/rsc.ts",
|
||||||
"types": "./src/exports/server/rsc.ts",
|
"types": "./src/exports/server/rsc.ts",
|
||||||
@@ -355,7 +360,7 @@
|
|||||||
"mdast-util-from-markdown": "2.0.2",
|
"mdast-util-from-markdown": "2.0.2",
|
||||||
"mdast-util-mdx-jsx": "3.1.3",
|
"mdast-util-mdx-jsx": "3.1.3",
|
||||||
"micromark-extension-mdx-jsx": "3.0.1",
|
"micromark-extension-mdx-jsx": "3.0.1",
|
||||||
"react-error-boundary": "4.0.13",
|
"react-error-boundary": "4.1.1",
|
||||||
"ts-essentials": "10.0.3",
|
"ts-essentials": "10.0.3",
|
||||||
"uuid": "10.0.0"
|
"uuid": "10.0.0"
|
||||||
},
|
},
|
||||||
@@ -413,6 +418,11 @@
|
|||||||
"types": "./dist/exports/client/index.d.ts",
|
"types": "./dist/exports/client/index.d.ts",
|
||||||
"default": "./dist/exports/client/index.js"
|
"default": "./dist/exports/client/index.js"
|
||||||
},
|
},
|
||||||
|
"./react": {
|
||||||
|
"import": "./dist/exports/react/index.js",
|
||||||
|
"types": "./dist/exports/react/index.d.ts",
|
||||||
|
"default": "./dist/exports/react/index.js"
|
||||||
|
},
|
||||||
"./rsc": {
|
"./rsc": {
|
||||||
"import": "./dist/exports/server/rsc.js",
|
"import": "./dist/exports/server/rsc.js",
|
||||||
"types": "./dist/exports/server/rsc.d.ts",
|
"types": "./dist/exports/server/rsc.d.ts",
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { SerializedQuoteNode } from '../../../../../../nodeTypes.js'
|
||||||
|
import type { JSXConverters } from '../types.js'
|
||||||
|
|
||||||
|
export const BlockquoteJSXConverter: JSXConverters<SerializedQuoteNode> = {
|
||||||
|
quote: ({ node, nodesToJSX }) => {
|
||||||
|
const children = nodesToJSX({
|
||||||
|
nodes: node.children,
|
||||||
|
})
|
||||||
|
|
||||||
|
return <blockquote>{children}</blockquote>
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { SerializedHeadingNode } from '../../../../../../nodeTypes.js'
|
||||||
|
import type { JSXConverters } from '../types.js'
|
||||||
|
|
||||||
|
export const HeadingJSXConverter: JSXConverters<SerializedHeadingNode> = {
|
||||||
|
heading: ({ node, nodesToJSX }) => {
|
||||||
|
const children = nodesToJSX({
|
||||||
|
nodes: node.children,
|
||||||
|
})
|
||||||
|
|
||||||
|
const NodeTag = node.tag
|
||||||
|
|
||||||
|
return <NodeTag>{children}</NodeTag>
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { SerializedHorizontalRuleNode } from '../../../../../../nodeTypes.js'
|
||||||
|
import type { JSXConverters } from '../types.js'
|
||||||
|
export const HorizontalRuleJSXConverter: JSXConverters<SerializedHorizontalRuleNode> = {
|
||||||
|
horizontalrule: () => {
|
||||||
|
return <hr />
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { SerializedLineBreakNode } from '../../../../../../nodeTypes.js'
|
||||||
|
import type { JSXConverters } from '../types.js'
|
||||||
|
|
||||||
|
export const LinebreakJSXConverter: JSXConverters<SerializedLineBreakNode> = {
|
||||||
|
linebreak: () => {
|
||||||
|
return <br />
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { SerializedAutoLinkNode, SerializedLinkNode } from '../../../../../../nodeTypes.js'
|
||||||
|
import type { JSXConverters } from '../types.js'
|
||||||
|
|
||||||
|
export const LinkJSXConverter: (args: {
|
||||||
|
internalDocToHref?: (args: { linkNode: SerializedLinkNode }) => string
|
||||||
|
}) => JSXConverters<SerializedAutoLinkNode | SerializedLinkNode> = ({ internalDocToHref }) => ({
|
||||||
|
autolink: ({ node, nodesToJSX }) => {
|
||||||
|
const children = nodesToJSX({
|
||||||
|
nodes: node.children,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rel: string | undefined = node.fields.newTab ? 'noopener noreferrer' : undefined
|
||||||
|
const target: string | undefined = node.fields.newTab ? '_blank' : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={node.fields.url} {...{ rel, target }}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
link: ({ node, nodesToJSX }) => {
|
||||||
|
const children = nodesToJSX({
|
||||||
|
nodes: node.children,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rel: string | undefined = node.fields.newTab ? 'noopener noreferrer' : undefined
|
||||||
|
const target: string | undefined = node.fields.newTab ? '_blank' : undefined
|
||||||
|
|
||||||
|
let href: string = node.fields.url
|
||||||
|
if (node.fields.linkType === 'internal') {
|
||||||
|
if (internalDocToHref) {
|
||||||
|
href = internalDocToHref({ linkNode: node })
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
'Lexical => JSX converter: Link converter: found internal link, but internalDocToHref is not provided',
|
||||||
|
)
|
||||||
|
href = '#' // fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={href} {...{ rel, target }}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
|
import type { SerializedListItemNode, SerializedListNode } from '../../../../../../nodeTypes.js'
|
||||||
|
import type { JSXConverters } from '../types.js'
|
||||||
|
|
||||||
|
export const ListJSXConverter: JSXConverters<SerializedListItemNode | SerializedListNode> = {
|
||||||
|
list: ({ node, nodesToJSX }) => {
|
||||||
|
const children = nodesToJSX({
|
||||||
|
nodes: node.children,
|
||||||
|
})
|
||||||
|
|
||||||
|
const NodeTag = node.tag
|
||||||
|
|
||||||
|
return <NodeTag className={`list-${node?.listType}`}>{children}</NodeTag>
|
||||||
|
},
|
||||||
|
listitem: ({ node, nodesToJSX, parent }) => {
|
||||||
|
const hasSubLists = node.children.some((child) => child.type === 'list')
|
||||||
|
|
||||||
|
const children = nodesToJSX({
|
||||||
|
nodes: node.children,
|
||||||
|
})
|
||||||
|
|
||||||
|
if ('listType' in parent && parent?.listType === 'check') {
|
||||||
|
const uuid = uuidv4()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
aria-checked={node.checked ? 'true' : 'false'}
|
||||||
|
className={`list-item-checkbox${node.checked ? ' list-item-checkbox-checked' : ' list-item-checkbox-unchecked'}${hasSubLists ? ' nestedListItem' : ''}`}
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
|
||||||
|
role="checkbox"
|
||||||
|
style={{ listStyleType: 'none' }}
|
||||||
|
tabIndex={-1}
|
||||||
|
value={node?.value}
|
||||||
|
>
|
||||||
|
{hasSubLists ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input checked={node.checked} id={uuid} type="checkbox" />
|
||||||
|
<label htmlFor={uuid}>{children}</label>
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={hasSubLists ? 'nestedListItem' : ''}
|
||||||
|
style={hasSubLists ? { listStyleType: 'none' } : {}}
|
||||||
|
value={node?.value}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { SerializedParagraphNode } from '../../../../../../nodeTypes.js'
|
||||||
|
import type { JSXConverters } from '../types.js'
|
||||||
|
|
||||||
|
export const ParagraphJSXConverter: JSXConverters<SerializedParagraphNode> = {
|
||||||
|
paragraph: ({ node, nodesToJSX }) => {
|
||||||
|
const children = nodesToJSX({
|
||||||
|
nodes: node.children,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!children?.length) {
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
<br />
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <p>{children}</p>
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type {
|
||||||
|
SerializedTableCellNode,
|
||||||
|
SerializedTableNode,
|
||||||
|
SerializedTableRowNode,
|
||||||
|
} from '../../../../../../nodeTypes.js'
|
||||||
|
import type { JSXConverters } from '../types.js'
|
||||||
|
|
||||||
|
export const TableJSXConverter: JSXConverters<
|
||||||
|
SerializedTableCellNode | SerializedTableNode | SerializedTableRowNode
|
||||||
|
> = {
|
||||||
|
table: ({ node, nodesToJSX }) => {
|
||||||
|
const children = nodesToJSX({
|
||||||
|
nodes: node.children,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<table className="lexical-table" style={{ borderCollapse: 'collapse' }}>
|
||||||
|
<tbody>{children}</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
tablecell: ({ node, nodesToJSX }) => {
|
||||||
|
const children = nodesToJSX({
|
||||||
|
nodes: node.children,
|
||||||
|
})
|
||||||
|
|
||||||
|
const TagName = node.headerState > 0 ? 'th' : 'td' // Use capital letter to denote a component
|
||||||
|
const headerStateClass = `lexical-table-cell-header-${node.headerState}`
|
||||||
|
const style = {
|
||||||
|
backgroundColor: node.backgroundColor || undefined, // Use undefined to avoid setting the style property if not needed
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
padding: '8px',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: JSX does not support setting attributes directly as strings, so you must convert the colSpan and rowSpan to numbers
|
||||||
|
const colSpan = node.colSpan && node.colSpan > 1 ? node.colSpan : undefined
|
||||||
|
const rowSpan = node.rowSpan && node.rowSpan > 1 ? node.rowSpan : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TagName
|
||||||
|
className={`lexical-table-cell ${headerStateClass}`}
|
||||||
|
colSpan={colSpan} // colSpan and rowSpan will only be added if they are not null
|
||||||
|
rowSpan={rowSpan}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TagName>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
tablerow: ({ node, nodesToJSX }) => {
|
||||||
|
const children = nodesToJSX({
|
||||||
|
nodes: node.children,
|
||||||
|
})
|
||||||
|
return <tr className="lexical-table-row">{children}</tr>
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import escapeHTML from 'escape-html'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import type { SerializedTextNode } from '../../../../../../nodeTypes.js'
|
||||||
|
import type { JSXConverters } from '../types.js'
|
||||||
|
|
||||||
|
import { NodeFormat } from '../../../../../../lexical/utils/nodeFormat.js'
|
||||||
|
|
||||||
|
export const TextJSXConverter: JSXConverters<SerializedTextNode> = {
|
||||||
|
text: ({ node }) => {
|
||||||
|
let text: React.ReactNode = <React.Fragment>{escapeHTML(node.text)}</React.Fragment>
|
||||||
|
|
||||||
|
if (node.format & NodeFormat.IS_BOLD) {
|
||||||
|
text = <strong>{text}</strong>
|
||||||
|
}
|
||||||
|
if (node.format & NodeFormat.IS_ITALIC) {
|
||||||
|
text = <em>{text}</em>
|
||||||
|
}
|
||||||
|
if (node.format & NodeFormat.IS_STRIKETHROUGH) {
|
||||||
|
text = <span style={{ textDecoration: 'line-through' }}>{text}</span>
|
||||||
|
}
|
||||||
|
if (node.format & NodeFormat.IS_UNDERLINE) {
|
||||||
|
text = <span style={{ textDecoration: 'underline' }}>{text}</span>
|
||||||
|
}
|
||||||
|
if (node.format & NodeFormat.IS_CODE) {
|
||||||
|
text = <code>{text}</code>
|
||||||
|
}
|
||||||
|
if (node.format & NodeFormat.IS_SUBSCRIPT) {
|
||||||
|
text = <sub>{text}</sub>
|
||||||
|
}
|
||||||
|
if (node.format & NodeFormat.IS_SUPERSCRIPT) {
|
||||||
|
text = <sup>{text}</sup>
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { FileData, FileSize, TypeWithID } from 'payload'
|
||||||
|
|
||||||
|
import type { SerializedUploadNode } from '../../../../../../nodeTypes.js'
|
||||||
|
import type { JSXConverters } from '../types.js'
|
||||||
|
|
||||||
|
export const UploadJSXConverter: JSXConverters<SerializedUploadNode> = {
|
||||||
|
upload: ({ node }) => {
|
||||||
|
const uploadDocument: {
|
||||||
|
value?: FileData & TypeWithID
|
||||||
|
} = node as any
|
||||||
|
|
||||||
|
const url = uploadDocument?.value?.url
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the upload is not an image, return a link to the upload
|
||||||
|
*/
|
||||||
|
if (!uploadDocument?.value?.mimeType?.startsWith('image')) {
|
||||||
|
return (
|
||||||
|
<a href={url} rel="noopener noreferrer">
|
||||||
|
{uploadDocument.value?.filename}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the upload is a simple image with no different sizes, return a simple img tag
|
||||||
|
*/
|
||||||
|
if (!uploadDocument?.value?.sizes || !Object.keys(uploadDocument?.value?.sizes).length) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
alt={uploadDocument?.value?.filename}
|
||||||
|
height={uploadDocument?.value?.height}
|
||||||
|
src={url}
|
||||||
|
width={uploadDocument?.value?.width}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the upload is an image with different sizes, return a picture element
|
||||||
|
*/
|
||||||
|
const pictureJSX: React.ReactNode[] = []
|
||||||
|
|
||||||
|
// Iterate through each size in the data.sizes object
|
||||||
|
for (const size in uploadDocument.value?.sizes) {
|
||||||
|
const imageSize: {
|
||||||
|
url?: string
|
||||||
|
} & FileSize = uploadDocument.value?.sizes[size]
|
||||||
|
|
||||||
|
// Skip if any property of the size object is null
|
||||||
|
if (
|
||||||
|
!imageSize.width ||
|
||||||
|
!imageSize.height ||
|
||||||
|
!imageSize.mimeType ||
|
||||||
|
!imageSize.filesize ||
|
||||||
|
!imageSize.filename ||
|
||||||
|
!imageSize.url
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const imageSizeURL = imageSize?.url
|
||||||
|
|
||||||
|
pictureJSX.push(
|
||||||
|
<source
|
||||||
|
key={size}
|
||||||
|
media="(max-width: ${imageSize.width}px)"
|
||||||
|
srcSet={imageSizeURL}
|
||||||
|
type={imageSize.mimeType}
|
||||||
|
></source>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the default img tag
|
||||||
|
pictureJSX.push(
|
||||||
|
<img
|
||||||
|
alt={uploadDocument.value?.filename}
|
||||||
|
height={uploadDocument.value?.height}
|
||||||
|
key={'image'}
|
||||||
|
src={url}
|
||||||
|
width={uploadDocument.value?.width}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
return <picture>{pictureJSX}</picture>
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { JSXConverters } from './types.js'
|
||||||
|
|
||||||
|
import { BlockquoteJSXConverter } from './converters/blockquote.js'
|
||||||
|
import { HeadingJSXConverter } from './converters/heading.js'
|
||||||
|
import { HorizontalRuleJSXConverter } from './converters/horizontalRule.js'
|
||||||
|
import { LinebreakJSXConverter } from './converters/linebreak.js'
|
||||||
|
import { LinkJSXConverter } from './converters/link.js'
|
||||||
|
import { ListJSXConverter } from './converters/list.js'
|
||||||
|
import { ParagraphJSXConverter } from './converters/paragraph.js'
|
||||||
|
import { TableJSXConverter } from './converters/table.js'
|
||||||
|
import { TextJSXConverter } from './converters/text.js'
|
||||||
|
import { UploadJSXConverter } from './converters/upload.js'
|
||||||
|
|
||||||
|
export const defaultJSXConverters: JSXConverters = {
|
||||||
|
...ParagraphJSXConverter,
|
||||||
|
...TextJSXConverter,
|
||||||
|
...LinebreakJSXConverter,
|
||||||
|
...BlockquoteJSXConverter,
|
||||||
|
...TableJSXConverter,
|
||||||
|
...HeadingJSXConverter,
|
||||||
|
...HorizontalRuleJSXConverter,
|
||||||
|
...ListJSXConverter,
|
||||||
|
...LinkJSXConverter({}),
|
||||||
|
...UploadJSXConverter,
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import type { SerializedEditorState, SerializedLexicalNode } from 'lexical'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import type { SerializedBlockNode, SerializedInlineBlockNode } from '../../../../../nodeTypes.js'
|
||||||
|
import type { JSXConverter, JSXConverters, SerializedLexicalNodeWithParent } from './types.js'
|
||||||
|
|
||||||
|
export type ConvertLexicalToHTMLArgs = {
|
||||||
|
converters: JSXConverters
|
||||||
|
data: SerializedEditorState
|
||||||
|
disableIndent?: boolean | string[]
|
||||||
|
disableTextAlign?: boolean | string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertLexicalToJSX({
|
||||||
|
converters,
|
||||||
|
data,
|
||||||
|
disableIndent,
|
||||||
|
disableTextAlign,
|
||||||
|
}: ConvertLexicalToHTMLArgs): React.ReactNode {
|
||||||
|
if (data?.root?.children?.length) {
|
||||||
|
return convertLexicalNodesToJSX({
|
||||||
|
converters,
|
||||||
|
disableIndent,
|
||||||
|
disableTextAlign,
|
||||||
|
nodes: data?.root?.children,
|
||||||
|
parent: data?.root,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertLexicalNodesToJSX({
|
||||||
|
converters,
|
||||||
|
disableIndent,
|
||||||
|
disableTextAlign,
|
||||||
|
nodes,
|
||||||
|
parent,
|
||||||
|
}: {
|
||||||
|
converters: JSXConverters
|
||||||
|
disableIndent?: boolean | string[]
|
||||||
|
disableTextAlign?: boolean | string[]
|
||||||
|
nodes: SerializedLexicalNode[]
|
||||||
|
parent: SerializedLexicalNodeWithParent
|
||||||
|
}): React.ReactNode[] {
|
||||||
|
const unknownConverter: JSXConverter<any> = converters.unknown as JSXConverter<any>
|
||||||
|
|
||||||
|
const jsxArray: React.ReactNode[] = nodes.map((node, i) => {
|
||||||
|
let converterForNode: JSXConverter<any> | undefined
|
||||||
|
if (node.type === 'block') {
|
||||||
|
converterForNode = converters?.blocks?.[(node as SerializedBlockNode)?.fields?.blockType]
|
||||||
|
if (!converterForNode) {
|
||||||
|
console.error(
|
||||||
|
`Lexical => JSX converter: Blocks converter: found ${(node as SerializedBlockNode)?.fields?.blockType} block, but no converter is provided`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (node.type === 'inlineBlock') {
|
||||||
|
converterForNode =
|
||||||
|
converters?.inlineBlocks?.[(node as SerializedInlineBlockNode)?.fields?.blockType]
|
||||||
|
if (!converterForNode) {
|
||||||
|
console.error(
|
||||||
|
`Lexical => JSX converter: Inline Blocks converter: found ${(node as SerializedInlineBlockNode)?.fields?.blockType} inline block, but no converter is provided`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
converterForNode = converters[node.type] as JSXConverter<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!converterForNode) {
|
||||||
|
if (unknownConverter) {
|
||||||
|
return unknownConverter({
|
||||||
|
childIndex: i,
|
||||||
|
converters,
|
||||||
|
node,
|
||||||
|
nodesToJSX: (args) => {
|
||||||
|
return convertLexicalNodesToJSX({
|
||||||
|
converters: args.converters ?? converters,
|
||||||
|
disableIndent: args.disableIndent ?? disableIndent,
|
||||||
|
disableTextAlign: args.disableTextAlign ?? disableTextAlign,
|
||||||
|
nodes: args.nodes,
|
||||||
|
parent: args.parent ?? {
|
||||||
|
...node,
|
||||||
|
parent,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
parent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return <span key={i}>unknown node</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const reactNode = converterForNode({
|
||||||
|
childIndex: i,
|
||||||
|
converters,
|
||||||
|
node,
|
||||||
|
nodesToJSX: (args) => {
|
||||||
|
return convertLexicalNodesToJSX({
|
||||||
|
converters: args.converters ?? converters,
|
||||||
|
disableIndent: args.disableIndent ?? disableIndent,
|
||||||
|
disableTextAlign: args.disableTextAlign ?? disableTextAlign,
|
||||||
|
nodes: args.nodes,
|
||||||
|
parent: args.parent ?? {
|
||||||
|
...node,
|
||||||
|
parent,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
parent,
|
||||||
|
})
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {}
|
||||||
|
|
||||||
|
// Check if disableTextAlign is not true and does not include node type
|
||||||
|
if (
|
||||||
|
!disableTextAlign &&
|
||||||
|
(!Array.isArray(disableTextAlign) || !disableTextAlign?.includes(node.type))
|
||||||
|
) {
|
||||||
|
if ('format' in node && node.format) {
|
||||||
|
switch (node.format) {
|
||||||
|
case 'center':
|
||||||
|
style.textAlign = 'center'
|
||||||
|
break
|
||||||
|
case 'end':
|
||||||
|
style.textAlign = 'right'
|
||||||
|
break
|
||||||
|
case 'justify':
|
||||||
|
style.textAlign = 'justify'
|
||||||
|
break
|
||||||
|
case 'left':
|
||||||
|
//style.textAlign = 'left'
|
||||||
|
// Do nothing, as left is the default
|
||||||
|
break
|
||||||
|
case 'right':
|
||||||
|
style.textAlign = 'right'
|
||||||
|
break
|
||||||
|
case 'start':
|
||||||
|
style.textAlign = 'left'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!disableIndent &&
|
||||||
|
(!Array.isArray(disableIndent) || !disableIndent?.includes(node.type))
|
||||||
|
) {
|
||||||
|
if ('indent' in node && node.indent) {
|
||||||
|
style.paddingInlineStart = `${Number(node.indent) * 2}em`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (React.isValidElement(reactNode)) {
|
||||||
|
// Inject style into reactNode
|
||||||
|
if (style.textAlign || style.paddingInlineStart) {
|
||||||
|
const newStyle = {
|
||||||
|
...style,
|
||||||
|
// @ts-expect-error type better later
|
||||||
|
...(reactNode?.props?.style ?? {}),
|
||||||
|
// reactNode style comes after, thus a textAlign specified in the converter has priority over the one we inject here
|
||||||
|
}
|
||||||
|
|
||||||
|
return React.cloneElement(reactNode, {
|
||||||
|
key: i,
|
||||||
|
// @ts-expect-error type better later
|
||||||
|
style: newStyle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return React.cloneElement(reactNode, {
|
||||||
|
key: i,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return reactNode
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error converting lexical node to HTML:', error, 'node:', node)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsxArray.filter(Boolean).map((jsx) => jsx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { SerializedLexicalNode } from 'lexical'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DefaultNodeTypes,
|
||||||
|
SerializedBlockNode,
|
||||||
|
SerializedInlineBlockNode,
|
||||||
|
} from '../../../../../nodeTypes.js'
|
||||||
|
export type JSXConverter<T extends { [key: string]: any; type?: string } = SerializedLexicalNode> =
|
||||||
|
(args: {
|
||||||
|
childIndex: number
|
||||||
|
converters: JSXConverters
|
||||||
|
node: T
|
||||||
|
nodesToJSX: (args: {
|
||||||
|
converters?: JSXConverters
|
||||||
|
disableIndent?: boolean | string[]
|
||||||
|
disableTextAlign?: boolean | string[]
|
||||||
|
nodes: SerializedLexicalNode[]
|
||||||
|
parent?: SerializedLexicalNodeWithParent
|
||||||
|
}) => React.ReactNode[]
|
||||||
|
parent: SerializedLexicalNodeWithParent
|
||||||
|
}) => React.ReactNode
|
||||||
|
export type JSXConverters<T extends { [key: string]: any; type?: string } = DefaultNodeTypes> = {
|
||||||
|
[key: string]:
|
||||||
|
| {
|
||||||
|
[blockSlug: string]: JSXConverter<any> // Not true, but need to appease TypeScript
|
||||||
|
}
|
||||||
|
| JSXConverter<any>
|
||||||
|
| undefined
|
||||||
|
} & {
|
||||||
|
[nodeType in NonNullable<T['type']>]?: JSXConverter<Extract<T, { type: nodeType }>>
|
||||||
|
} & {
|
||||||
|
blocks?: {
|
||||||
|
[blockSlug: string]: JSXConverter<{ fields: Record<string, any> } & SerializedBlockNode>
|
||||||
|
}
|
||||||
|
inlineBlocks?: {
|
||||||
|
[blockSlug: string]: JSXConverter<{ fields: Record<string, any> } & SerializedInlineBlockNode>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SerializedLexicalNodeWithParent = {
|
||||||
|
parent?: SerializedLexicalNode
|
||||||
|
} & SerializedLexicalNode
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { SerializedEditorState } from 'lexical'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import type { JSXConverters } from './converter/types.js'
|
||||||
|
|
||||||
|
import { defaultJSXConverters } from './converter/defaultConverters.js'
|
||||||
|
import { convertLexicalToJSX } from './converter/index.js'
|
||||||
|
|
||||||
|
export type JSXConvertersFunction = (args: { defaultConverters: JSXConverters }) => JSXConverters
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string
|
||||||
|
converters?: JSXConverters | JSXConvertersFunction
|
||||||
|
data: SerializedEditorState
|
||||||
|
disableIndent?: boolean | string[]
|
||||||
|
disableTextAlign?: boolean | string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RichText: React.FC<Props> = ({
|
||||||
|
className,
|
||||||
|
converters,
|
||||||
|
data: editorState,
|
||||||
|
disableIndent,
|
||||||
|
disableTextAlign,
|
||||||
|
}) => {
|
||||||
|
if (!editorState) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalConverters: JSXConverters = {}
|
||||||
|
if (converters) {
|
||||||
|
if (typeof converters === 'function') {
|
||||||
|
finalConverters = converters({ defaultConverters: defaultJSXConverters })
|
||||||
|
} else {
|
||||||
|
finalConverters = converters
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
finalConverters = defaultJSXConverters
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{editorState &&
|
||||||
|
!Array.isArray(editorState) &&
|
||||||
|
typeof editorState === 'object' &&
|
||||||
|
'root' in editorState &&
|
||||||
|
convertLexicalToJSX({
|
||||||
|
converters: finalConverters,
|
||||||
|
data: editorState,
|
||||||
|
disableIndent,
|
||||||
|
disableTextAlign,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
packages/richtext-lexical/src/exports/react/index.ts
Normal file
18
packages/richtext-lexical/src/exports/react/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export { BlockquoteJSXConverter } from './components/RichText/converter/converters/blockquote.js'
|
||||||
|
export { HeadingJSXConverter } from './components/RichText/converter/converters/heading.js'
|
||||||
|
export { HorizontalRuleJSXConverter } from './components/RichText/converter/converters/horizontalRule.js'
|
||||||
|
export { LinebreakJSXConverter } from './components/RichText/converter/converters/linebreak.js'
|
||||||
|
export { LinkJSXConverter } from './components/RichText/converter/converters/link.js'
|
||||||
|
export { ListJSXConverter } from './components/RichText/converter/converters/list.js'
|
||||||
|
export { ParagraphJSXConverter } from './components/RichText/converter/converters/paragraph.js'
|
||||||
|
export { TableJSXConverter } from './components/RichText/converter/converters/table.js'
|
||||||
|
export { TextJSXConverter } from './components/RichText/converter/converters/text.js'
|
||||||
|
export { UploadJSXConverter } from './components/RichText/converter/converters/upload.js'
|
||||||
|
|
||||||
|
export { defaultJSXConverters } from './components/RichText/converter/defaultConverters.js'
|
||||||
|
export { convertLexicalNodesToJSX } from './components/RichText/converter/index.js'
|
||||||
|
export type {
|
||||||
|
JSXConverters,
|
||||||
|
SerializedLexicalNodeWithParent,
|
||||||
|
} from './components/RichText/converter/types.js'
|
||||||
|
export { type JSXConvertersFunction, RichText } from './components/RichText/index.js'
|
||||||
@@ -8,19 +8,17 @@ import type {
|
|||||||
SerializedLexicalNode,
|
SerializedLexicalNode,
|
||||||
Spread,
|
Spread,
|
||||||
} from 'lexical'
|
} from 'lexical'
|
||||||
|
import type { JsonObject } from 'payload'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import type { JSX } from 'react'
|
import type { JSX } from 'react'
|
||||||
|
|
||||||
import ObjectID from 'bson-objectid'
|
import ObjectID from 'bson-objectid'
|
||||||
import { DecoratorNode } from 'lexical'
|
import { DecoratorNode } from 'lexical'
|
||||||
|
|
||||||
export type InlineBlockFields = {
|
export type InlineBlockFields<TInlineBlockFields extends JsonObject = JsonObject> = {
|
||||||
/** Block form data */
|
|
||||||
[key: string]: any
|
|
||||||
//blockName: string
|
|
||||||
blockType: string
|
blockType: string
|
||||||
id: string
|
id: string
|
||||||
}
|
} & TInlineBlockFields
|
||||||
|
|
||||||
export type SerializedServerInlineBlockNode = Spread<
|
export type SerializedServerInlineBlockNode = Spread<
|
||||||
{
|
{
|
||||||
|
|||||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@@ -1256,8 +1256,8 @@ importers:
|
|||||||
specifier: 19.0.0-rc-65a56d0e-20241020
|
specifier: 19.0.0-rc-65a56d0e-20241020
|
||||||
version: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
|
version: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
|
||||||
react-error-boundary:
|
react-error-boundary:
|
||||||
specifier: 4.0.13
|
specifier: 4.1.1
|
||||||
version: 4.0.13(react@19.0.0-rc-65a56d0e-20241020)
|
version: 4.1.1(react@19.0.0-rc-65a56d0e-20241020)
|
||||||
ts-essentials:
|
ts-essentials:
|
||||||
specifier: 10.0.3
|
specifier: 10.0.3
|
||||||
version: 10.0.3(typescript@5.7.2)
|
version: 10.0.3(typescript@5.7.2)
|
||||||
@@ -8621,8 +8621,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: 19.0.0-rc-65a56d0e-20241020
|
react: 19.0.0-rc-65a56d0e-20241020
|
||||||
|
|
||||||
react-error-boundary@4.0.13:
|
react-error-boundary@4.1.1:
|
||||||
resolution: {integrity: sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==}
|
resolution: {integrity: sha512-EOAEsbVm2EQD8zPS4m24SiaR/506RPC3CjMcjJ5JWKECsctyLsDTKxB26Hvl7jcz7KweSOkBYAcY/hmMpMn2jA==}
|
||||||
|
engines: {pnpm: '=9'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: 19.0.0-rc-65a56d0e-20241020
|
react: 19.0.0-rc-65a56d0e-20241020
|
||||||
|
|
||||||
@@ -18671,7 +18672,7 @@ snapshots:
|
|||||||
'@babel/runtime': 7.26.0
|
'@babel/runtime': 7.26.0
|
||||||
react: 19.0.0-rc-65a56d0e-20241020
|
react: 19.0.0-rc-65a56d0e-20241020
|
||||||
|
|
||||||
react-error-boundary@4.0.13(react@19.0.0-rc-65a56d0e-20241020):
|
react-error-boundary@4.1.1(react@19.0.0-rc-65a56d0e-20241020):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.26.0
|
'@babel/runtime': 7.26.0
|
||||||
react: 19.0.0-rc-65a56d0e-20241020
|
react: 19.0.0-rc-65a56d0e-20241020
|
||||||
|
|||||||
46
test/fields/collections/Lexical/LexicalRendered.tsx
Normal file
46
test/fields/collections/Lexical/LexicalRendered.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client'
|
||||||
|
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
|
||||||
|
|
||||||
|
import { type JSXConvertersFunction, RichText } from '@payloadcms/richtext-lexical/react'
|
||||||
|
import { useConfig, useDocumentInfo, usePayloadAPI } from '@payloadcms/ui'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
|
||||||
|
...defaultConverters,
|
||||||
|
blocks: {
|
||||||
|
myTextBlock: ({ node }) => <div style={{ backgroundColor: 'red' }}>{node.fields.text}</div>,
|
||||||
|
relationshipBlock: ({ node, nodesToJSX }) => {
|
||||||
|
return <p>Test</p>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const LexicalRendered: React.FC = () => {
|
||||||
|
const { id, collectionSlug } = useDocumentInfo()
|
||||||
|
|
||||||
|
const {
|
||||||
|
config: {
|
||||||
|
routes: { api },
|
||||||
|
serverURL,
|
||||||
|
},
|
||||||
|
} = useConfig()
|
||||||
|
|
||||||
|
const [{ data }] = usePayloadAPI(`${serverURL}${api}/${collectionSlug}/${id}`, {
|
||||||
|
initialParams: {
|
||||||
|
depth: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!data.lexicalWithBlocks) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Rendered:</h1>
|
||||||
|
<RichText converters={jsxConverters} data={data.lexicalWithBlocks as SerializedEditorState} />
|
||||||
|
<h1>Raw JSON:</h1>
|
||||||
|
<pre>{JSON.stringify(data.lexicalWithBlocks, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -305,6 +305,15 @@ export const LexicalFields: CollectionConfig = {
|
|||||||
}),
|
}),
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
//{
|
||||||
|
// name: 'rendered',
|
||||||
|
// type: 'ui',
|
||||||
|
// admin: {
|
||||||
|
// components: {
|
||||||
|
// Field: './collections/Lexical/LexicalRendered.js#LexicalRendered',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
//},
|
||||||
{
|
{
|
||||||
name: 'lexicalWithBlocks_markdown',
|
name: 'lexicalWithBlocks_markdown',
|
||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
|
|||||||
Reference in New Issue
Block a user