Last active
December 6, 2024 23:40
-
-
Save wkentdag/2dccedf9c61cd762789e24f25e9e2f3a to your computer and use it in GitHub Desktop.
Lexical rich text field for PayloadCMS with custom HTML conversion
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 { | |
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 | |
} |
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
// 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