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:
Alessio Gravili
2024-11-26 15:40:24 -07:00
committed by GitHub
parent 601759d967
commit bffd98f019
21 changed files with 798 additions and 13 deletions

View File

@@ -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
---
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 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.
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

View File

@@ -30,6 +30,11 @@
"types": "./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": {
"import": "./src/exports/server/rsc.ts",
"types": "./src/exports/server/rsc.ts",
@@ -355,7 +360,7 @@
"mdast-util-from-markdown": "2.0.2",
"mdast-util-mdx-jsx": "3.1.3",
"micromark-extension-mdx-jsx": "3.0.1",
"react-error-boundary": "4.0.13",
"react-error-boundary": "4.1.1",
"ts-essentials": "10.0.3",
"uuid": "10.0.0"
},
@@ -413,6 +418,11 @@
"types": "./dist/exports/client/index.d.ts",
"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": {
"import": "./dist/exports/server/rsc.js",
"types": "./dist/exports/server/rsc.d.ts",

View File

@@ -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>
},
}

View File

@@ -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>
},
}

View File

@@ -0,0 +1,7 @@
import type { SerializedHorizontalRuleNode } from '../../../../../../nodeTypes.js'
import type { JSXConverters } from '../types.js'
export const HorizontalRuleJSXConverter: JSXConverters<SerializedHorizontalRuleNode> = {
horizontalrule: () => {
return <hr />
},
}

View File

@@ -0,0 +1,8 @@
import type { SerializedLineBreakNode } from '../../../../../../nodeTypes.js'
import type { JSXConverters } from '../types.js'
export const LinebreakJSXConverter: JSXConverters<SerializedLineBreakNode> = {
linebreak: () => {
return <br />
},
}

View File

@@ -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>
)
},
})

View File

@@ -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>
)
}
},
}

View File

@@ -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>
},
}

View File

@@ -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>
},
}

View File

@@ -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
},
}

View File

@@ -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>
},
}

View File

@@ -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,
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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>
)
}

View 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'

View File

@@ -8,19 +8,17 @@ import type {
SerializedLexicalNode,
Spread,
} from 'lexical'
import type { JsonObject } from 'payload'
import type React from 'react'
import type { JSX } from 'react'
import ObjectID from 'bson-objectid'
import { DecoratorNode } from 'lexical'
export type InlineBlockFields = {
/** Block form data */
[key: string]: any
//blockName: string
export type InlineBlockFields<TInlineBlockFields extends JsonObject = JsonObject> = {
blockType: string
id: string
}
} & TInlineBlockFields
export type SerializedServerInlineBlockNode = Spread<
{

11
pnpm-lock.yaml generated
View File

@@ -1256,8 +1256,8 @@ importers:
specifier: 19.0.0-rc-65a56d0e-20241020
version: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react-error-boundary:
specifier: 4.0.13
version: 4.0.13(react@19.0.0-rc-65a56d0e-20241020)
specifier: 4.1.1
version: 4.1.1(react@19.0.0-rc-65a56d0e-20241020)
ts-essentials:
specifier: 10.0.3
version: 10.0.3(typescript@5.7.2)
@@ -8621,8 +8621,9 @@ packages:
peerDependencies:
react: 19.0.0-rc-65a56d0e-20241020
react-error-boundary@4.0.13:
resolution: {integrity: sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==}
react-error-boundary@4.1.1:
resolution: {integrity: sha512-EOAEsbVm2EQD8zPS4m24SiaR/506RPC3CjMcjJ5JWKECsctyLsDTKxB26Hvl7jcz7KweSOkBYAcY/hmMpMn2jA==}
engines: {pnpm: '=9'}
peerDependencies:
react: 19.0.0-rc-65a56d0e-20241020
@@ -18671,7 +18672,7 @@ snapshots:
'@babel/runtime': 7.26.0
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:
'@babel/runtime': 7.26.0
react: 19.0.0-rc-65a56d0e-20241020

View 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>
)
}

View File

@@ -305,6 +305,15 @@ export const LexicalFields: CollectionConfig = {
}),
required: true,
},
//{
// name: 'rendered',
// type: 'ui',
// admin: {
// components: {
// Field: './collections/Lexical/LexicalRendered.js#LexicalRendered',
// },
// },
//},
{
name: 'lexicalWithBlocks_markdown',
type: 'textarea',