Skip to content

Instantly share code, notes, and snippets.

@paulpopus
Created March 12, 2024 02:05
Show Gist options
  • Save paulpopus/77301b758078c30fddc5d551a12ccb8e to your computer and use it in GitHub Desktop.
Save paulpopus/77301b758078c30fddc5d551a12ccb8e to your computer and use it in GitHub Desktop.
Payload lexical rich text serialiser
//This copy-and-pasted from lexical here here: https://github.com/facebook/lexical/blob/c2ceee223f46543d12c574e62155e619f9a18a5d/packages/lexical/src/LexicalConstants.ts
import type { ElementFormatType, TextFormatType } from 'lexical'
import type { TextDetailType, TextModeType } from 'lexical/nodes/LexicalTextNode'
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
// DOM
export const DOM_ELEMENT_TYPE = 1
export const DOM_TEXT_TYPE = 3
// Reconciling
export const NO_DIRTY_NODES = 0
export const HAS_DIRTY_NODES = 1
export const FULL_RECONCILE = 2
// Text node modes
export const IS_NORMAL = 0
export const IS_TOKEN = 1
export const IS_SEGMENTED = 2
// IS_INERT = 3
// Text node formatting
export const IS_BOLD = 1
export const IS_ITALIC = 1 << 1
export const IS_STRIKETHROUGH = 1 << 2
export const IS_UNDERLINE = 1 << 3
export const IS_CODE = 1 << 4
export const IS_SUBSCRIPT = 1 << 5
export const IS_SUPERSCRIPT = 1 << 6
export const IS_HIGHLIGHT = 1 << 7
export const IS_ALL_FORMATTING =
IS_BOLD | IS_ITALIC | IS_STRIKETHROUGH | IS_UNDERLINE | IS_CODE | IS_SUBSCRIPT | IS_SUPERSCRIPT | IS_HIGHLIGHT
// Text node details
export const IS_DIRECTIONLESS = 1
export const IS_UNMERGEABLE = 1 << 1
// Element node formatting
export const IS_ALIGN_LEFT = 1
export const IS_ALIGN_CENTER = 2
export const IS_ALIGN_RIGHT = 3
export const IS_ALIGN_JUSTIFY = 4
export const IS_ALIGN_START = 5
export const IS_ALIGN_END = 6
// Reconciliation
export const NON_BREAKING_SPACE = '\u00A0'
const ZERO_WIDTH_SPACE = '\u200b'
export const DOUBLE_LINE_BREAK = '\n\n'
// For FF, we need to use a non-breaking space, or it gets composition
// in a stuck state.
const RTL = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC'
const LTR = 'A-Za-z\u00C0-\u00D6\u00D8-\u00F6' + '\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u200E\u2C00-\uFB1C' + '\uFE00-\uFE6F\uFEFD-\uFFFF'
// eslint-disable-next-line no-misleading-character-class
export const RTL_REGEX = new RegExp('^[^' + LTR + ']*[' + RTL + ']')
// eslint-disable-next-line no-misleading-character-class
export const LTR_REGEX = new RegExp('^[^' + RTL + ']*[' + LTR + ']')
export const TEXT_TYPE_TO_FORMAT: Record<TextFormatType | string, number> = {
bold: IS_BOLD,
code: IS_CODE,
highlight: IS_HIGHLIGHT,
italic: IS_ITALIC,
strikethrough: IS_STRIKETHROUGH,
subscript: IS_SUBSCRIPT,
superscript: IS_SUPERSCRIPT,
underline: IS_UNDERLINE,
}
export const DETAIL_TYPE_TO_DETAIL: Record<TextDetailType | string, number> = {
directionless: IS_DIRECTIONLESS,
unmergeable: IS_UNMERGEABLE,
}
export const ELEMENT_TYPE_TO_FORMAT: Record<Exclude<ElementFormatType, ''>, number> = {
center: IS_ALIGN_CENTER,
end: IS_ALIGN_END,
justify: IS_ALIGN_JUSTIFY,
left: IS_ALIGN_LEFT,
right: IS_ALIGN_RIGHT,
start: IS_ALIGN_START,
}
export const ELEMENT_FORMAT_TO_TYPE: Record<number, ElementFormatType> = {
[IS_ALIGN_CENTER]: 'center',
[IS_ALIGN_END]: 'end',
[IS_ALIGN_JUSTIFY]: 'justify',
[IS_ALIGN_LEFT]: 'left',
[IS_ALIGN_RIGHT]: 'right',
[IS_ALIGN_START]: 'start',
}
export const TEXT_MODE_TO_TYPE: Record<TextModeType, 0 | 1 | 2> = {
normal: IS_NORMAL,
segmented: IS_SEGMENTED,
token: IS_TOKEN,
}
export const TEXT_TYPE_TO_MODE: Record<number, TextModeType> = {
[IS_NORMAL]: 'normal',
[IS_SEGMENTED]: 'segmented',
[IS_TOKEN]: 'token',
}
import React from 'react'
import { serializeLexical } from './serialize'
const RichText: React.FC<{ className?: string; content: any }> = ({ className, content }) => {
if (!content) {
return null
}
return (
<div className={['typography prose', className].filter(Boolean).join(' ')}>
{content &&
!Array.isArray(content) &&
typeof content === 'object' &&
'root' in content &&
serializeLexical({ nodes: content?.root?.children })}
</div>
)
}
export default RichText
import type { SerializedListItemNode, SerializedListNode } from '@lexical/list'
import type { SerializedHeadingNode, SerializedQuoteNode } from '@lexical/rich-text'
import type { LinkFields, SerializedLinkNode } from '@payloadcms/richtext-lexical'
import type { SerializedElementNode, SerializedLexicalNode, SerializedTextNode } from 'lexical'
import escapeHTML from 'escape-html'
import Link from 'next/link'
import React, { Fragment } from 'react'
import CodeBlock from '@/components/RichText/blocks/Code'
import { IS_BOLD, IS_CODE, IS_ITALIC, IS_STRIKETHROUGH, IS_SUBSCRIPT, IS_SUPERSCRIPT, IS_UNDERLINE } from './nodeFormat'
import ImageBlock from '@/components/RichText/blocks/Image'
interface Props {
nodes: SerializedLexicalNode[]
}
export function serializeLexical({ nodes }: Props): JSX.Element {
return (
<Fragment>
{nodes?.map((_node, index): JSX.Element | null => {
if (_node.type === 'text') {
const node = _node as SerializedTextNode
let text = <React.Fragment key={index}>{node.text}</React.Fragment>
if (node.format & IS_BOLD) {
text = <strong key={index}>{text}</strong>
}
if (node.format & IS_ITALIC) {
text = <em key={index}>{text}</em>
}
if (node.format & IS_STRIKETHROUGH) {
text = (
<span key={index} style={{ textDecoration: 'line-through' }}>
{text}
</span>
)
}
if (node.format & IS_UNDERLINE) {
text = (
<span key={index} style={{ textDecoration: 'underline' }}>
{text}
</span>
)
}
if (node.format & IS_CODE) {
text = <code key={index}>{node.text}</code>
}
if (node.format & IS_SUBSCRIPT) {
text = <sub key={index}>{text}</sub>
}
if (node.format & IS_SUPERSCRIPT) {
text = <sup key={index}>{text}</sup>
}
return text
}
if (_node == null) {
return null
}
// NOTE: Hacky fix for
// https://github.com/facebook/lexical/blob/d10c4e6e55261b2fdd7d1845aed46151d0f06a8c/packages/lexical-list/src/LexicalListItemNode.ts#L133
// which does not return checked: false (only true - i.e. there is no prop for false)
const serializedChildrenFn = (node: SerializedElementNode): JSX.Element | null => {
if (node.children == null) {
return null
} else {
if (node?.type === 'list' && (node as SerializedListNode)?.listType === 'check') {
for (const item of node.children) {
if ('checked' in item) {
if (!item?.checked) {
item.checked = false
}
}
}
return serializeLexical({ nodes: node.children })
} else {
return serializeLexical({ nodes: node.children })
}
}
}
const serializedChildren = 'children' in _node ? serializedChildrenFn(_node as SerializedElementNode) : ''
switch (_node.type) {
case 'linebreak': {
return <br key={index} />
}
case 'paragraph': {
return <p key={index}>{serializedChildren}</p>
}
case 'heading': {
const node = _node as SerializedHeadingNode
type Heading = Extract<keyof JSX.IntrinsicElements, 'h1' | 'h2' | 'h3' | 'h4' | 'h5'>
const Tag = node?.tag as Heading
return <Tag key={index}>{serializedChildren}</Tag>
}
case 'list': {
const node = _node as SerializedListNode
type List = Extract<keyof JSX.IntrinsicElements, 'ol' | 'ul'>
const Tag = node?.tag as List
return (
<Tag className={`list`} key={index}>
{serializedChildren}
</Tag>
)
}
case 'listitem': {
const node = _node as SerializedListItemNode
if (node?.checked != null) {
return (
<li
aria-checked={node.checked ? 'true' : 'false'}
className={`component--list-item-checkbox ${
node.checked ? 'component--list-item-checkbox-checked' : 'component--list-item-checked-unchecked'
}`}
key={index}
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
role='checkbox'
tabIndex={-1}
value={node?.value}>
{serializedChildren}
</li>
)
} else {
return (
<li key={index} value={node?.value}>
{serializedChildren}
</li>
)
}
}
case 'quote': {
const node = _node as SerializedQuoteNode
return <blockquote key={index}>{serializedChildren}</blockquote>
}
case 'code': {
const node = _node as SerializedQuoteNode
return <code key={index}>{serializedChildren}</code>
}
case 'link': {
const node = _node as SerializedLinkNode
const fields: LinkFields = node.fields
if (fields.linkType === 'custom') {
const rel = fields.newTab ? 'noopener noreferrer' : undefined
return (
<a
href={escapeHTML(fields.url)}
key={index}
{...(fields?.newTab
? {
rel: 'noopener noreferrer',
target: '_blank',
}
: {})}>
{serializedChildren}
</a>
)
} else {
return <span key={index}>Internal link coming soon</span>
}
}
case 'block':
//@ts-expect-error
const blockType = _node.fields.data.blockType
if (!blockType) {
return null
}
switch (blockType) {
case 'codeBlock':
// @ts-expect-error
return <CodeBlock key={index} code={_node.fields.data.code} language={_node.fields.data.language} />
case 'imageBlock':
// @ts-expect-error
return <ImageBlock key={index} media={_node.fields.data.image} />
default:
return null
}
default:
return null
}
})}
</Fragment>
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment