From bffd98f01947c9b708e61d8a1c8cd23037fed2e8 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Tue, 26 Nov 2024 15:40:24 -0700 Subject: [PATCH] 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 }) =>
{node.fields.text}
, }, }) export const MyComponent = ({ lexicalContent }) => { return ( ) } ``` --- docs/lexical/converters.mdx | 57 +++++- packages/richtext-lexical/package.json | 12 +- .../converter/converters/blockquote.tsx | 12 ++ .../RichText/converter/converters/heading.tsx | 14 ++ .../converter/converters/horizontalRule.tsx | 7 + .../converter/converters/linebreak.tsx | 8 + .../RichText/converter/converters/link.tsx | 47 +++++ .../RichText/converter/converters/list.tsx | 59 ++++++ .../converter/converters/paragraph.tsx | 20 ++ .../RichText/converter/converters/table.tsx | 55 ++++++ .../RichText/converter/converters/text.tsx | 37 ++++ .../RichText/converter/converters/upload.tsx | 85 ++++++++ .../RichText/converter/defaultConverters.ts | 25 +++ .../components/RichText/converter/index.tsx | 183 ++++++++++++++++++ .../components/RichText/converter/types.ts | 42 ++++ .../react/components/RichText/index.tsx | 56 ++++++ .../src/exports/react/index.ts | 18 ++ .../blocks/server/nodes/InlineBlocksNode.tsx | 8 +- pnpm-lock.yaml | 11 +- .../collections/Lexical/LexicalRendered.tsx | 46 +++++ test/fields/collections/Lexical/index.ts | 9 + 21 files changed, 798 insertions(+), 13 deletions(-) create mode 100644 packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/blockquote.tsx create mode 100644 packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/heading.tsx create mode 100644 packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/horizontalRule.tsx create mode 100644 packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/linebreak.tsx create mode 100644 packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/link.tsx create mode 100644 packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/list.tsx create mode 100644 packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/paragraph.tsx create mode 100644 packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/table.tsx create mode 100644 packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/text.tsx create mode 100644 packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/upload.tsx create mode 100644 packages/richtext-lexical/src/exports/react/components/RichText/converter/defaultConverters.ts create mode 100644 packages/richtext-lexical/src/exports/react/components/RichText/converter/index.tsx create mode 100644 packages/richtext-lexical/src/exports/react/components/RichText/converter/types.ts create mode 100644 packages/richtext-lexical/src/exports/react/components/RichText/index.tsx create mode 100644 packages/richtext-lexical/src/exports/react/index.ts create mode 100644 test/fields/collections/Lexical/LexicalRendered.tsx diff --git a/docs/lexical/converters.mdx b/docs/lexical/converters.mdx index b08753cf4..da191ed05 100644 --- a/docs/lexical/converters.mdx +++ b/docs/lexical/converters.mdx @@ -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 ( + + ) +} +``` + +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. + + + 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. + + +### 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 }) =>
{node.fields.text}
, + }, +}) + +export const MyComponent = ({ lexicalData }) => { + return ( + + ) +} +``` + ## 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 diff --git a/packages/richtext-lexical/package.json b/packages/richtext-lexical/package.json index 7e6b46fe9..e4dcbc015 100644 --- a/packages/richtext-lexical/package.json +++ b/packages/richtext-lexical/package.json @@ -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", diff --git a/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/blockquote.tsx b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/blockquote.tsx new file mode 100644 index 000000000..ad40b0648 --- /dev/null +++ b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/blockquote.tsx @@ -0,0 +1,12 @@ +import type { SerializedQuoteNode } from '../../../../../../nodeTypes.js' +import type { JSXConverters } from '../types.js' + +export const BlockquoteJSXConverter: JSXConverters = { + quote: ({ node, nodesToJSX }) => { + const children = nodesToJSX({ + nodes: node.children, + }) + + return
{children}
+ }, +} diff --git a/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/heading.tsx b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/heading.tsx new file mode 100644 index 000000000..0c10b2cd1 --- /dev/null +++ b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/heading.tsx @@ -0,0 +1,14 @@ +import type { SerializedHeadingNode } from '../../../../../../nodeTypes.js' +import type { JSXConverters } from '../types.js' + +export const HeadingJSXConverter: JSXConverters = { + heading: ({ node, nodesToJSX }) => { + const children = nodesToJSX({ + nodes: node.children, + }) + + const NodeTag = node.tag + + return {children} + }, +} diff --git a/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/horizontalRule.tsx b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/horizontalRule.tsx new file mode 100644 index 000000000..fc25a9fe9 --- /dev/null +++ b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/horizontalRule.tsx @@ -0,0 +1,7 @@ +import type { SerializedHorizontalRuleNode } from '../../../../../../nodeTypes.js' +import type { JSXConverters } from '../types.js' +export const HorizontalRuleJSXConverter: JSXConverters = { + horizontalrule: () => { + return
+ }, +} diff --git a/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/linebreak.tsx b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/linebreak.tsx new file mode 100644 index 000000000..9b769b417 --- /dev/null +++ b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/linebreak.tsx @@ -0,0 +1,8 @@ +import type { SerializedLineBreakNode } from '../../../../../../nodeTypes.js' +import type { JSXConverters } from '../types.js' + +export const LinebreakJSXConverter: JSXConverters = { + linebreak: () => { + return
+ }, +} diff --git a/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/link.tsx b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/link.tsx new file mode 100644 index 000000000..9d94e3602 --- /dev/null +++ b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/link.tsx @@ -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 = ({ 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 ( + + {children} + + ) + }, + 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 ( + + {children} + + ) + }, +}) diff --git a/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/list.tsx b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/list.tsx new file mode 100644 index 000000000..d203ae83a --- /dev/null +++ b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/list.tsx @@ -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 = { + list: ({ node, nodesToJSX }) => { + const children = nodesToJSX({ + nodes: node.children, + }) + + const NodeTag = node.tag + + return {children} + }, + 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 ( +
  • + {hasSubLists ? ( + children + ) : ( + <> + + +
    + + )} +
  • + ) + } else { + return ( +
  • + {children} +
  • + ) + } + }, +} diff --git a/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/paragraph.tsx b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/paragraph.tsx new file mode 100644 index 000000000..751b4144b --- /dev/null +++ b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/paragraph.tsx @@ -0,0 +1,20 @@ +import type { SerializedParagraphNode } from '../../../../../../nodeTypes.js' +import type { JSXConverters } from '../types.js' + +export const ParagraphJSXConverter: JSXConverters = { + paragraph: ({ node, nodesToJSX }) => { + const children = nodesToJSX({ + nodes: node.children, + }) + + if (!children?.length) { + return ( +

    +
    +

    + ) + } + + return

    {children}

    + }, +} diff --git a/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/table.tsx b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/table.tsx new file mode 100644 index 000000000..6f42f7a00 --- /dev/null +++ b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/table.tsx @@ -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 ( + + {children} +
    + ) + }, + 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 ( + + {children} + + ) + }, + tablerow: ({ node, nodesToJSX }) => { + const children = nodesToJSX({ + nodes: node.children, + }) + return {children} + }, +} diff --git a/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/text.tsx b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/text.tsx new file mode 100644 index 000000000..5c84c7271 --- /dev/null +++ b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/text.tsx @@ -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 = { + text: ({ node }) => { + let text: React.ReactNode = {escapeHTML(node.text)} + + if (node.format & NodeFormat.IS_BOLD) { + text = {text} + } + if (node.format & NodeFormat.IS_ITALIC) { + text = {text} + } + if (node.format & NodeFormat.IS_STRIKETHROUGH) { + text = {text} + } + if (node.format & NodeFormat.IS_UNDERLINE) { + text = {text} + } + if (node.format & NodeFormat.IS_CODE) { + text = {text} + } + if (node.format & NodeFormat.IS_SUBSCRIPT) { + text = {text} + } + if (node.format & NodeFormat.IS_SUPERSCRIPT) { + text = {text} + } + + return text + }, +} diff --git a/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/upload.tsx b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/upload.tsx new file mode 100644 index 000000000..4c83b7cbd --- /dev/null +++ b/packages/richtext-lexical/src/exports/react/components/RichText/converter/converters/upload.tsx @@ -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 = { + 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 ( + + {uploadDocument.value?.filename} + + ) + } + + /** + * 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 ( + {uploadDocument?.value?.filename} + ) + } + + /** + * 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( + , + ) + } + + // Add the default img tag + pictureJSX.push( + {uploadDocument.value?.filename}, + ) + return {pictureJSX} + }, +} diff --git a/packages/richtext-lexical/src/exports/react/components/RichText/converter/defaultConverters.ts b/packages/richtext-lexical/src/exports/react/components/RichText/converter/defaultConverters.ts new file mode 100644 index 000000000..4037c971c --- /dev/null +++ b/packages/richtext-lexical/src/exports/react/components/RichText/converter/defaultConverters.ts @@ -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, +} diff --git a/packages/richtext-lexical/src/exports/react/components/RichText/converter/index.tsx b/packages/richtext-lexical/src/exports/react/components/RichText/converter/index.tsx new file mode 100644 index 000000000..decc4ae55 --- /dev/null +++ b/packages/richtext-lexical/src/exports/react/components/RichText/converter/index.tsx @@ -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 = converters.unknown as JSXConverter + + const jsxArray: React.ReactNode[] = nodes.map((node, i) => { + let converterForNode: JSXConverter | 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 + } + + 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 unknown node + } + + 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) +} diff --git a/packages/richtext-lexical/src/exports/react/components/RichText/converter/types.ts b/packages/richtext-lexical/src/exports/react/components/RichText/converter/types.ts new file mode 100644 index 000000000..f08608620 --- /dev/null +++ b/packages/richtext-lexical/src/exports/react/components/RichText/converter/types.ts @@ -0,0 +1,42 @@ +import type { SerializedLexicalNode } from 'lexical' + +import type { + DefaultNodeTypes, + SerializedBlockNode, + SerializedInlineBlockNode, +} from '../../../../../nodeTypes.js' +export type JSXConverter = + (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 = { + [key: string]: + | { + [blockSlug: string]: JSXConverter // Not true, but need to appease TypeScript + } + | JSXConverter + | undefined +} & { + [nodeType in NonNullable]?: JSXConverter> +} & { + blocks?: { + [blockSlug: string]: JSXConverter<{ fields: Record } & SerializedBlockNode> + } + inlineBlocks?: { + [blockSlug: string]: JSXConverter<{ fields: Record } & SerializedInlineBlockNode> + } +} + +export type SerializedLexicalNodeWithParent = { + parent?: SerializedLexicalNode +} & SerializedLexicalNode diff --git a/packages/richtext-lexical/src/exports/react/components/RichText/index.tsx b/packages/richtext-lexical/src/exports/react/components/RichText/index.tsx new file mode 100644 index 000000000..4c8a4a030 --- /dev/null +++ b/packages/richtext-lexical/src/exports/react/components/RichText/index.tsx @@ -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 = ({ + 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 ( +
    + {editorState && + !Array.isArray(editorState) && + typeof editorState === 'object' && + 'root' in editorState && + convertLexicalToJSX({ + converters: finalConverters, + data: editorState, + disableIndent, + disableTextAlign, + })} +
    + ) +} diff --git a/packages/richtext-lexical/src/exports/react/index.ts b/packages/richtext-lexical/src/exports/react/index.ts new file mode 100644 index 000000000..47fff2955 --- /dev/null +++ b/packages/richtext-lexical/src/exports/react/index.ts @@ -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' diff --git a/packages/richtext-lexical/src/features/blocks/server/nodes/InlineBlocksNode.tsx b/packages/richtext-lexical/src/features/blocks/server/nodes/InlineBlocksNode.tsx index 834de23e7..078238bed 100644 --- a/packages/richtext-lexical/src/features/blocks/server/nodes/InlineBlocksNode.tsx +++ b/packages/richtext-lexical/src/features/blocks/server/nodes/InlineBlocksNode.tsx @@ -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 = { blockType: string id: string -} +} & TInlineBlockFields export type SerializedServerInlineBlockNode = Spread< { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 761a7ea60..de4ee1c67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/test/fields/collections/Lexical/LexicalRendered.tsx b/test/fields/collections/Lexical/LexicalRendered.tsx new file mode 100644 index 000000000..838a0ea01 --- /dev/null +++ b/test/fields/collections/Lexical/LexicalRendered.tsx @@ -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 }) =>
    {node.fields.text}
    , + relationshipBlock: ({ node, nodesToJSX }) => { + return

    Test

    + }, + }, +}) + +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 ( +
    +

    Rendered:

    + +

    Raw JSON:

    +
    {JSON.stringify(data.lexicalWithBlocks, null, 2)}
    +
    + ) +} diff --git a/test/fields/collections/Lexical/index.ts b/test/fields/collections/Lexical/index.ts index 223b484cf..1f1e488a2 100644 --- a/test/fields/collections/Lexical/index.ts +++ b/test/fields/collections/Lexical/index.ts @@ -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',