feat(richtext-lexical): new HTML converter (#11370)
Deprecates the old HTML converter and introduces a new one that functions similarly to our Lexical => JSX converter.
The old converter had the following limitations:
- It imported the entire lexical bundle
- It was challenging to implement. The sanitized lexical editor config had to be passed in as an argument, which was difficult to obtain
- It only worked on the server
This new HTML converter is lightweight, user-friendly, and works on both server and client. Instead of retrieving HTML converters from the editor config, they can be explicitly provided to the converter function.
By default, the converter expects populated data to function properly. If you need to use unpopulated data (e.g., when running it from a hook), you also have the option to use the async HTML converter, exported from `@payloadcms/richtext-lexical/html-async`, and provide a `populate` function - this function will then be used to dynamically populate nodes during the conversion process.
## Example 1 - generating HTML in your frontend
```tsx
'use client'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
import React from 'react'
export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
const html = convertLexicalToHTML({ data })
return <div dangerouslySetInnerHTML={{ __html: html }} />
}
```
## Example - converting Lexical Blocks
```tsx
'use client'
import type { MyInlineBlock, MyTextBlock } from '@/payload-types'
import type {
DefaultNodeTypes,
SerializedBlockNode,
SerializedInlineBlockNode,
} from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import {
convertLexicalToHTML,
type HTMLConvertersFunction,
} from '@payloadcms/richtext-lexical/html'
import React from 'react'
type NodeTypes =
| DefaultNodeTypes
| SerializedBlockNode<MyTextBlock>
| SerializedInlineBlockNode<MyInlineBlock>
const htmlConverters: HTMLConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({
...defaultConverters,
blocks: {
// Each key should match your block's slug
myTextBlock: ({ node, providedCSSString }) =>
`<div style="background-color: red;${providedCSSString}">${node.fields.text}</div>`,
},
inlineBlocks: {
// Each key should match your inline block's slug
myInlineBlock: ({ node, providedStyleTag }) =>
`<span${providedStyleTag}>${node.fields.text}</span$>`,
},
})
export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
const html = convertLexicalToHTML({
converters: htmlConverters,
data,
})
return <div dangerouslySetInnerHTML={{ __html: html }} />
}
```
## Example 3 - outputting HTML from the collection
```ts
import type { HTMLConvertersFunction } from '@payloadcms/richtext-lexical/html'
import type { MyTextBlock } from '@/payload-types.js'
import type { CollectionConfig } from 'payload'
import {
BlocksFeature,
type DefaultNodeTypes,
lexicalEditor,
lexicalHTMLField,
type SerializedBlockNode,
} from '@payloadcms/richtext-lexical'
const Pages: CollectionConfig = {
slug: 'pages',
fields: [
{
name: 'nameOfYourRichTextField',
type: 'richText',
editor: lexicalEditor(),
},
lexicalHTMLField({
htmlFieldName: 'nameOfYourRichTextField_html',
lexicalFieldName: 'nameOfYourRichTextField',
}),
{
name: 'customRichText',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({
blocks: [
{
interfaceName: 'MyTextBlock',
slug: 'myTextBlock',
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
}),
],
}),
},
lexicalHTMLField({
htmlFieldName: 'customRichText_html',
lexicalFieldName: 'customRichText',
// can pass in additional converters or override default ones
converters: (({ defaultConverters }) => ({
...defaultConverters,
blocks: {
myTextBlock: ({ node, providedCSSString }) =>
`<div style="background-color: red;${providedCSSString}">${node.fields.text}</div>`,
},
})) as HTMLConvertersFunction<DefaultNodeTypes | SerializedBlockNode<MyTextBlock>>,
}),
],
}
```
This commit is contained in:
@@ -1,11 +1,23 @@
|
||||
'use client'
|
||||
import type { DefaultNodeTypes, SerializedBlockNode } from '@payloadcms/richtext-lexical'
|
||||
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
|
||||
|
||||
import { getRestPopulateFn } from '@payloadcms/richtext-lexical/client'
|
||||
import {
|
||||
convertLexicalToHTML,
|
||||
type HTMLConvertersFunction,
|
||||
} from '@payloadcms/richtext-lexical/html'
|
||||
import {
|
||||
convertLexicalToHTMLAsync,
|
||||
type HTMLConvertersFunctionAsync,
|
||||
} from '@payloadcms/richtext-lexical/html-async'
|
||||
import { type JSXConvertersFunction, RichText } from '@payloadcms/richtext-lexical/react'
|
||||
import { useConfig, useDocumentInfo, usePayloadAPI } from '@payloadcms/ui'
|
||||
import React from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
|
||||
const jsxConverters: JSXConvertersFunction<DefaultNodeTypes | SerializedBlockNode<any>> = ({
|
||||
defaultConverters,
|
||||
}) => ({
|
||||
...defaultConverters,
|
||||
blocks: {
|
||||
myTextBlock: ({ node }) => <div style={{ backgroundColor: 'red' }}>{node.fields.text}</div>,
|
||||
@@ -15,6 +27,30 @@ const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
|
||||
},
|
||||
})
|
||||
|
||||
const htmlConverters: HTMLConvertersFunction<DefaultNodeTypes | SerializedBlockNode<any>> = ({
|
||||
defaultConverters,
|
||||
}) => ({
|
||||
...defaultConverters,
|
||||
blocks: {
|
||||
myTextBlock: ({ node }) => `<div style="background-color: red;">${node.fields.text}</div>`,
|
||||
relationshipBlock: () => {
|
||||
return `<p>Test</p>`
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const htmlConvertersAsync: HTMLConvertersFunctionAsync<
|
||||
DefaultNodeTypes | SerializedBlockNode<any>
|
||||
> = ({ defaultConverters }) => ({
|
||||
...defaultConverters,
|
||||
blocks: {
|
||||
myTextBlock: ({ node }) => `<div style="background-color: red;">${node.fields.text}</div>`,
|
||||
relationshipBlock: () => {
|
||||
return `<p>Test</p>`
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const LexicalRendered: React.FC = () => {
|
||||
const { id, collectionSlug } = useDocumentInfo()
|
||||
|
||||
@@ -31,14 +67,54 @@ export const LexicalRendered: React.FC = () => {
|
||||
},
|
||||
})
|
||||
|
||||
const [{ data: unpopulatedData }] = usePayloadAPI(`${serverURL}${api}/${collectionSlug}/${id}`, {
|
||||
initialParams: {
|
||||
depth: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const html: null | string = useMemo(() => {
|
||||
if (!data.lexicalWithBlocks) {
|
||||
return null
|
||||
}
|
||||
|
||||
return convertLexicalToHTML({
|
||||
converters: htmlConverters,
|
||||
data: data.lexicalWithBlocks as SerializedEditorState,
|
||||
})
|
||||
}, [data.lexicalWithBlocks])
|
||||
|
||||
const [htmlFromUnpopulatedData, setHtmlFromUnpopulatedData] = useState<null | string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function convert() {
|
||||
const html = await convertLexicalToHTMLAsync({
|
||||
converters: htmlConvertersAsync,
|
||||
data: unpopulatedData.lexicalWithBlocks as SerializedEditorState,
|
||||
populate: getRestPopulateFn({
|
||||
apiURL: `${serverURL}${api}`,
|
||||
}),
|
||||
})
|
||||
|
||||
setHtmlFromUnpopulatedData(html)
|
||||
}
|
||||
void convert()
|
||||
}, [unpopulatedData.lexicalWithBlocks, api, serverURL])
|
||||
|
||||
if (!data.lexicalWithBlocks) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Rendered:</h1>
|
||||
<h1>Rendered JSX:</h1>
|
||||
<RichText converters={jsxConverters} data={data.lexicalWithBlocks as SerializedEditorState} />
|
||||
<h1>Rendered HTML:</h1>
|
||||
{html && <div dangerouslySetInnerHTML={{ __html: html }} />}
|
||||
<h1>Rendered HTML 2:</h1>
|
||||
{htmlFromUnpopulatedData && (
|
||||
<div dangerouslySetInnerHTML={{ __html: htmlFromUnpopulatedData }} />
|
||||
)}
|
||||
<h1>Raw JSON:</h1>
|
||||
<pre>{JSON.stringify(data.lexicalWithBlocks, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import {
|
||||
HTMLConverterFeature,
|
||||
lexicalEditor,
|
||||
lexicalHTML,
|
||||
lexicalHTMLField,
|
||||
LinkFeature,
|
||||
TreeViewFeature,
|
||||
UploadFeature,
|
||||
@@ -39,7 +38,6 @@ export const LexicalMigrateFields: CollectionConfig = {
|
||||
...defaultFeatures,
|
||||
LexicalPluginToLexicalFeature({ quiet: true }),
|
||||
TreeViewFeature(),
|
||||
HTMLConverterFeature(),
|
||||
LinkFeature({
|
||||
fields: ({ defaultFields }) => [
|
||||
...defaultFields,
|
||||
@@ -80,7 +78,6 @@ export const LexicalMigrateFields: CollectionConfig = {
|
||||
...defaultFeatures,
|
||||
SlateToLexicalFeature(),
|
||||
TreeViewFeature(),
|
||||
HTMLConverterFeature(),
|
||||
LinkFeature({
|
||||
fields: ({ defaultFields }) => [
|
||||
...defaultFields,
|
||||
@@ -117,11 +114,11 @@ export const LexicalMigrateFields: CollectionConfig = {
|
||||
name: 'lexicalSimple',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [...defaultFeatures, HTMLConverterFeature()],
|
||||
features: ({ defaultFeatures }) => [...defaultFeatures],
|
||||
}),
|
||||
defaultValue: getSimpleLexicalData('simple'),
|
||||
},
|
||||
lexicalHTML('lexicalSimple', { name: 'lexicalSimple_html' }),
|
||||
lexicalHTMLField({ htmlFieldName: 'lexicalSimple_html', lexicalFieldName: 'lexicalSimple' }),
|
||||
{
|
||||
name: 'groupWithLexicalField',
|
||||
type: 'group',
|
||||
@@ -130,11 +127,14 @@ export const LexicalMigrateFields: CollectionConfig = {
|
||||
name: 'lexicalInGroupField',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [...defaultFeatures, HTMLConverterFeature()],
|
||||
features: ({ defaultFeatures }) => [...defaultFeatures],
|
||||
}),
|
||||
defaultValue: getSimpleLexicalData('group'),
|
||||
},
|
||||
lexicalHTML('lexicalInGroupField', { name: 'lexicalInGroupField_html' }),
|
||||
lexicalHTMLField({
|
||||
htmlFieldName: 'lexicalInGroupField_html',
|
||||
lexicalFieldName: 'lexicalInGroupField',
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -145,10 +145,13 @@ export const LexicalMigrateFields: CollectionConfig = {
|
||||
name: 'lexicalInArrayField',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [...defaultFeatures, HTMLConverterFeature()],
|
||||
features: ({ defaultFeatures }) => [...defaultFeatures],
|
||||
}),
|
||||
},
|
||||
lexicalHTML('lexicalInArrayField', { name: 'lexicalInArrayField_html' }),
|
||||
lexicalHTMLField({
|
||||
htmlFieldName: 'lexicalInArrayField_html',
|
||||
lexicalFieldName: 'lexicalInArrayField',
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -304,8 +304,8 @@ describe('Lexical', () => {
|
||||
})
|
||||
).docs[0] as never
|
||||
|
||||
const htmlField: string = lexicalDoc?.lexicalSimple_html
|
||||
expect(htmlField).toStrictEqual('<p>simple</p>')
|
||||
const htmlField = lexicalDoc?.lexicalSimple_html
|
||||
expect(htmlField).toStrictEqual('<div class="payload-richtext"><p>simple</p></div>')
|
||||
})
|
||||
it('htmlConverter: should output correct HTML for lexical field nested in group', async () => {
|
||||
const lexicalDoc: LexicalMigrateField = (
|
||||
@@ -320,8 +320,8 @@ describe('Lexical', () => {
|
||||
})
|
||||
).docs[0] as never
|
||||
|
||||
const htmlField: string = lexicalDoc?.groupWithLexicalField?.lexicalInGroupField_html
|
||||
expect(htmlField).toStrictEqual('<p>group</p>')
|
||||
const htmlField = lexicalDoc?.groupWithLexicalField?.lexicalInGroupField_html
|
||||
expect(htmlField).toStrictEqual('<div class="payload-richtext"><p>group</p></div>')
|
||||
})
|
||||
it('htmlConverter: should output correct HTML for lexical field nested in array', async () => {
|
||||
const lexicalDoc: LexicalMigrateField = (
|
||||
@@ -336,11 +336,11 @@ describe('Lexical', () => {
|
||||
})
|
||||
).docs[0] as never
|
||||
|
||||
const htmlField1: string = lexicalDoc?.arrayWithLexicalField[0].lexicalInArrayField_html
|
||||
const htmlField2: string = lexicalDoc?.arrayWithLexicalField[1].lexicalInArrayField_html
|
||||
const htmlField1 = lexicalDoc?.arrayWithLexicalField?.[0]?.lexicalInArrayField_html
|
||||
const htmlField2 = lexicalDoc?.arrayWithLexicalField?.[1]?.lexicalInArrayField_html
|
||||
|
||||
expect(htmlField1).toStrictEqual('<p>array 1</p>')
|
||||
expect(htmlField2).toStrictEqual('<p>array 2</p>')
|
||||
expect(htmlField1).toStrictEqual('<div class="payload-richtext"><p>array 1</p></div>')
|
||||
expect(htmlField2).toStrictEqual('<div class="payload-richtext"><p>array 2</p></div>')
|
||||
})
|
||||
})
|
||||
describe('advanced - blocks', () => {
|
||||
@@ -654,7 +654,7 @@ describe('Lexical', () => {
|
||||
locale: 'en',
|
||||
data: {
|
||||
title: 'Localized Lexical hooks',
|
||||
lexicalBlocksLocalized: textToLexicalJSON({ text: 'some text' }) as any,
|
||||
lexicalBlocksLocalized: textToLexicalJSON({ text: 'some text' }),
|
||||
lexicalBlocksSubLocalized: generateLexicalLocalizedRichText(
|
||||
'Shared text',
|
||||
'English text in block',
|
||||
|
||||
Reference in New Issue
Block a user