Skip to content

Instantly share code, notes, and snippets.

@wkentdag
Last active December 6, 2024 23:40
Show Gist options
  • Save wkentdag/2dccedf9c61cd762789e24f25e9e2f3a to your computer and use it in GitHub Desktop.
Save wkentdag/2dccedf9c61cd762789e24f25e9e2f3a to your computer and use it in GitHub Desktop.
Lexical rich text field for PayloadCMS with custom HTML conversion
import {
consolidateHTMLConverters,
convertLexicalToHTML,
lexicalEditor,
sanitizeEditorConfig,
} from '@payloadcms/richtext-lexical'
import { createHash } from 'crypto'
import stringify from 'json-stable-stringify'
import { SerializedEditorState } from 'lexical'
import { FieldBase, RichTextField, TextField } from 'payload/types'
import defaultLexicalConfig, { simple } from '../config/lexical'
import { capitalize, isEmpty } from '../utils'
/**
* @param name field name
* @param lexicalConfig use `simple` for a marks-only editor
* @returns a tuple of fields: richText field `name`, and string field `name_html`
*/
export const richTextFields = (
{
name,
label,
lexicalConfig,
...rest
}: Pick<FieldBase, 'name' | 'label'> &
Partial<RichTextField> & {
lexicalConfig?: 'simple'
} = {
name: 'content',
label: 'Content',
lexicalConfig: undefined,
},
): [RichTextField, TextField] => {
if (!label) {
label = capitalize(name)
}
const editorConfig =
lexicalConfig === 'simple' ? simple : defaultLexicalConfig
return [
{
...rest,
name,
label,
type: 'richText',
editor: lexicalEditor({ features: editorConfig.features }),
hooks: {
...(rest?.hooks || {}),
beforeChange: [
...(rest?.hooks?.beforeChange || []),
({ value }) => {
// clean up the empty node that dirty lexical editor leaves behind
if (value && isEmpty(value)) {
value = null
}
return value
},
],
},
},
{
/**
* custom HTML conversion field adapted from Payload's builtin `lexicalHTML` helper,
* this version is optimized to only perform the HTML conversion when the source lexical content changes.
* vs the builtin version, which performs the conversion each time the document is read
* @see https://github.com/payloadcms/payload/blob/2.x/packages/richtext-lexical/src/field/features/converters/html/field/index.ts#L47
*/
name: `${name}_html`,
type: 'text',
admin: {
hidden: true,
},
hooks: {
beforeChange: [
async ({ value, siblingData, originalDoc }) => {
const existingHTML: string = value
const content: SerializedEditorState = siblingData[name]
const previousContent: SerializedEditorState = originalDoc[name]
// if content is null or empty, exit early, (re)setting HTML to empty
if (!content || isEmpty(content)) {
// console.log(name, 'empty content, skipping HTML conversion')
return ''
}
// if content is unchanged, skip the conversion and reuse the existing HTML
if (
existingHTML &&
content &&
previousContent &&
!isEmpty(content) &&
!isEmpty(previousContent) &&
hashString(content) === hashString(previousContent)
) {
// console.log(name, 'unchanged content; reusing HTML content')
return existingHTML
}
// console.log(name, 'content updated; converting to HTML')
// content is truthy and updated; convert it to HTML and store.
const converters = consolidateHTMLConverters({
editorConfig: sanitizeEditorConfig(editorConfig),
})
return await convertLexicalToHTML({
converters,
data: content,
})
},
],
},
},
]
}
/**
* generate deterministic hash string for an object for quick equality comparisons
*/
const hashString = (obj: object) => {
const serialized = stringify(obj)
const hash = createHash('md5').update(serialized).digest('hex')
return hash
}
// https://stackoverflow.com/questions/75493899/how-to-check-if-the-editor-is-empty
export const isEmpty = (node?: { root: SerializedRootNode }) => {
if (!node || !node?.root) {
return true
}
const { root } = node
const isRoot = root.type === 'root'
const hasSingleChild = root.children.length < 2
const hasNestedChild = root.children.some((childNode) => {
// @ts-expect-error @TODO why is `children` not in the typedef?
if (childNode?.children) {
// @ts-expect-error saa
return childNode.children.length > 0
}
return false
})
return isRoot && hasSingleChild && !hasNestedChild
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment