Skip to content

Instantly share code, notes, and snippets.

@elliott-w
Last active May 14, 2025 06:53
Show Gist options
  • Save elliott-w/8300e99769ef5e688ea3b810c9001b03 to your computer and use it in GitHub Desktop.
Save elliott-w/8300e99769ef5e688ea3b810c9001b03 to your computer and use it in GitHub Desktop.
Payload CMS - Custom Select field to select headings from rich text field
import { CollectionConfig } from 'payload'
import type { SelectHeadingFromRichTextFieldProps } from '../components/SelectHeadingFromRichTextField'
export const Articles: CollectionConfig = {
slug: 'articles',
fields: [
{
type: 'array',
name: 'tableOfContents',
labels: {
plural: 'Anchor Links',
singular: 'Anchor Link',
},
fields: [
{
type: 'row',
fields: [
{
type: 'text',
name: 'title',
required: true,
admin: {
components: {
Field: {
path: 'app/(payload)/components/SelectHeadingFromRichTextField',
clientProps: {
richTextSourceField: 'content',
} satisfies SelectHeadingFromRichTextFieldProps,
},
},
},
},
{
type: 'text',
name: 'overrideTitle',
},
],
},
],
},
{
name: 'content',
type: 'richText',
required: true,
},
],
}
'use client'
import {
FieldLabel,
SelectField,
useDebounce,
useField,
useFormFields,
} from '@payloadcms/ui'
import { Option, type SelectFieldClientProps } from 'payload'
import { useMemo } from 'react'
import { formatSlug } from '../utils/formatSlug'
export interface SelectHeadingFromRichTextFieldProps {
richTextSourceField: string
}
type Props = SelectFieldClientProps & SelectHeadingFromRichTextFieldProps
export default function SelectHeadingFromRichTextField(props: Props) {
const { path, field, richTextSourceField } = props
const { setValue, value } = useField<string>({ path })
const onChange = (option: Option | Option[]) => {
setValue(option)
}
const richTextField = useFormFields(([fields]) => fields[richTextSourceField])
const debouncedValue = useDebounce(richTextField.value, 1000)
const options = useMemo(
() =>
extractHeadings(debouncedValue).map((heading) => ({
label: heading,
value: formatSlug(heading),
})),
[debouncedValue]
)
const id = useMemo(() => 'field-' + path.split('.').join('__'), [path])
return (
<div className="field-type select" id={id} style={{ flex: '1 1 auto' }}>
<FieldLabel
label={field?.label || field?.name}
path={path}
required={field?.required}
/>
<SelectField
path={path}
field={{
name: path,
hasMany: false,
options,
}}
value={value}
onChange={onChange}
/>
</div>
)
}
/**
* Recursively extracts all text content from a given Lexical node and its children.
*/
const extractTextFromNode = (node: any): string => {
let text = ''
if (node.type === 'text' && typeof node.text === 'string') {
text += node.text
}
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
text += extractTextFromNode(child)
}
}
return text
}
/**
* Extracts all heading tags inner text from a Lexical JSON payload.
*/
const extractHeadings = (lexicalObject: any): string[] => {
const headings: string[] = []
if (!lexicalObject || !lexicalObject.root || !lexicalObject.root.children) {
return headings
}
const findHeadingsRecursive = (nodes: any[]) => {
for (const node of nodes) {
if (node.type === 'heading') {
const innerText = extractTextFromNode(node)
headings.push(innerText.trim())
} else if (node.children && Array.isArray(node.children)) {
findHeadingsRecursive(node.children)
}
}
}
findHeadingsRecursive(lexicalObject.root.children)
return headings
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment