Created
March 12, 2024 02:05
-
-
Save paulpopus/77301b758078c30fddc5d551a12ccb8e to your computer and use it in GitHub Desktop.
Payload lexical rich text serialiser
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//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', | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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