feat(richtext-lexical): add editorConfigFactory helper to streamline getting the editor config (#11467)

This PR exports a new `editorConfigFactory` that provides multiple standardized ways to retrieve the editor configuration needed for the Lexical editor.

## Why this is needed

Getting the editor config is required for converting the lexical editor state into/from different formats, as it's needed to create a headless editor. While we're moving away from requiring headless editor instantiation for common format conversions, some conversion types and other use cases still require it.

Currently, retrieving the editor config is cumbersome - you either need an existing field to extract it from or the payload config to create it from scratch, with multiple approaches for each method.

## What this PR does

The `editorConfigFactory` consolidates all possible ways to retrieve the editor config into a single factory with clear methods:

```ts
editorConfigFactory.default()
editorConfigFactory.fromField()
editorConfigFactory.fromUnsanitizedField()
editorConfigFactory.fromFeatures()
editorConfigFactory.fromEditor()
```

This results in less code, simpler implementation, and improved developer experience. The PR also adds documentation for all retrieval methods.
This commit is contained in:
Alessio Gravili
2025-03-01 16:44:25 -07:00
committed by GitHub
parent 927078c4db
commit e1b30842fb
4 changed files with 254 additions and 118 deletions

View File

@@ -346,48 +346,73 @@ A headless editor can perform such conversions outside of the main editor instan
```ts
import { createHeadlessEditor } from '@payloadcms/richtext-lexical/lexical/headless'
import { getEnabledNodes, sanitizeServerEditorConfig } from '@payloadcms/richtext-lexical'
import { getEnabledNodes, editorConfigFactory } from '@payloadcms/richtext-lexical'
const yourEditorConfig // <= your editor config here
const payloadConfig // <= your Payload Config here
const headlessEditor = createHeadlessEditor({
nodes: getEnabledNodes({
editorConfig: sanitizeServerEditorConfig(yourEditorConfig, payloadConfig),
editorConfig: await editorConfigFactory.default({config: payloadConfig})
}),
})
```
### Getting the editor config
As you can see, you need to provide an editor config in order to create a headless editor. This is because the editor config is used to determine which nodes & features are enabled, and which converters are used.
You need to provide an editor config in order to create a headless editor. This is because the editor config is used to determine which nodes & features are enabled, and which converters are used.
To get the editor config, simply import the default editor config and adjust it - just like you did inside of the `editor: lexicalEditor({})` property:
To get the editor config, import the `editorConfigFactory` factory - this factory provides a variety of ways to get the editor config, depending on your use case.
```ts
import { defaultEditorConfig, defaultEditorFeatures } from '@payloadcms/richtext-lexical' // <= make sure this package is installed
import type { SanitizedConfig } from 'payload'
const yourEditorConfig = defaultEditorConfig
import {
editorConfigFactory,
FixedToolbarFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
// If you made changes to the features of the field's editor config, you should also make those changes here:
yourEditorConfig.features = [
...defaultEditorFeatures,
// Add your custom features here
]
// Your config needs to be available in order to retrieve the default editor config
const config: SanitizedConfig = {} as SanitizedConfig
// Version 1 - use the default editor config
const yourEditorConfig = await editorConfigFactory.default({ config })
// Version 2 - if you have access to a lexical fields, you can extract the editor config from it
const yourEditorConfig2 = editorConfigFactory.fromField({
field: collectionConfig.fields[1],
})
// Version 3 - create a new editor config - behaves just like instantiating a new `lexicalEditor`
const yourEditorConfig3 = await editorConfigFactory.fromFeatures({
config,
features: ({ defaultFeatures }) => [...defaultFeatures, FixedToolbarFeature()],
})
// Version 4 - if you have instantiated a lexical editor and are accessing it outside a field (=> this is the unsanitized editor),
// you can extract the editor config from it.
// This is common if you define the editor in a re-usable module scope variable and pass it to the richText field.
// This is the least efficient way to get the editor config, and not recommended. It is recommended to extract the `features` arg
// into a separate variable and use `fromFeatures` instead.
const editor = lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures, FixedToolbarFeature()],
})
const yourEditorConfig4 = await editorConfigFactory.fromEditor({
config,
editor,
})
```
### Getting the editor config from an existing field
### Example - Getting the editor config from an existing field
If you have access to the sanitized collection config, you can get access to the lexical sanitized editor config & features, as every lexical richText field returns it. Here is an example how you can get it from another field's afterRead hook:
```ts
import type { CollectionConfig, RichTextField } from 'payload'
import { editorConfigFactory, getEnabledNodes, lexicalEditor } from '@payloadcms/richtext-lexical'
import { createHeadlessEditor } from '@payloadcms/richtext-lexical/lexical/headless'
import type { LexicalRichTextAdapter, SanitizedServerEditorConfig } from '@payloadcms/richtext-lexical'
import {
getEnabledNodes,
lexicalEditor
} from '@payloadcms/richtext-lexical'
export const MyCollection: CollectionConfig = {
slug: 'slug',
@@ -397,20 +422,18 @@ export const MyCollection: CollectionConfig = {
type: 'text',
hooks: {
afterRead: [
({ value, collection }) => {
const otherRichTextField: RichTextField = collection.fields.find(
({ siblingFields, value }) => {
const field: RichTextField = siblingFields.find(
(field) => 'name' in field && field.name === 'richText',
) as RichTextField
const lexicalAdapter: LexicalRichTextAdapter =
otherRichTextField.editor as LexicalRichTextAdapter
const sanitizedServerEditorConfig: SanitizedServerEditorConfig =
lexicalAdapter.editorConfig
const editorConfig = editorConfigFactory.fromField({
field,
})
const headlessEditor = createHeadlessEditor({
nodes: getEnabledNodes({
editorConfig: sanitizedServerEditorConfig,
editorConfig,
}),
})
@@ -424,11 +447,9 @@ export const MyCollection: CollectionConfig = {
{
name: 'richText',
type: 'richText',
editor: lexicalEditor({
features,
}),
}
]
editor: lexicalEditor(),
},
],
}
```
@@ -479,14 +500,14 @@ This has been taken from the [lexical serialization & deserialization docs](http
Convert markdown content to the Lexical editor format with the following:
```ts
import { sanitizeServerEditorConfig, $convertFromMarkdownString } from '@payloadcms/richtext-lexical'
import { $convertFromMarkdownString, editorConfigFactory } from '@payloadcms/richtext-lexical'
const yourSanitizedEditorConfig = sanitizeServerEditorConfig(yourEditorConfig, payloadConfig) // <= your editor config & Payload Config here
const yourEditorConfig = await editorConfigFactory.default({ config })
const markdown = `# Hello World`
headlessEditor.update(
() => {
$convertFromMarkdownString(markdown, yourSanitizedEditorConfig.features.markdownTransformers)
$convertFromMarkdownString(markdown, yourEditorConfig.features.markdownTransformers)
},
{ discrete: true },
)
@@ -505,11 +526,12 @@ Export content from the Lexical editor into Markdown format using these steps:
Here's the code for it:
```ts
import { $convertToMarkdownString } from '@payloadcms/richtext-lexical/lexical/markdown'
import { sanitizeServerEditorConfig } from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
const yourSanitizedEditorConfig = sanitizeServerEditorConfig(yourEditorConfig, payloadConfig) // <= your editor config & Payload Config here
import { editorConfigFactory } from '@payloadcms/richtext-lexical'
import { $convertToMarkdownString } from '@payloadcms/richtext-lexical/lexical/markdown'
const yourEditorConfig = await editorConfigFactory.default({ config })
const yourEditorState: SerializedEditorState // <= your current editor state here
// Import editor state into your headless editor
@@ -527,7 +549,7 @@ try {
// Export to markdown
let markdown: string
headlessEditor.getEditorState().read(() => {
markdown = $convertToMarkdownString(yourSanitizedEditorConfig?.features?.markdownTransformers)
markdown = $convertToMarkdownString(yourEditorConfig?.features?.markdownTransformers)
})
```
@@ -542,6 +564,7 @@ Here's the code for it:
```ts
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { $getRoot } from '@payloadcms/richtext-lexical/lexical'
const yourEditorState: SerializedEditorState // <= your current editor state here
@@ -554,7 +577,7 @@ try {
},
{ discrete: true }, // This should commit the editor state immediately
)
} catch (e) {
} catch (e) {
logger.error({ err: e }, 'ERROR parsing editor state')
}

View File

@@ -12,19 +12,13 @@ import {
import type { FeatureProviderServer, ResolvedServerFeatureMap } from './features/typesServer.js'
import type { SanitizedServerEditorConfig } from './lexical/config/types.js'
import type {
AdapterProps,
LexicalEditorProps,
LexicalRichTextAdapter,
LexicalRichTextAdapterProvider,
} from './types.js'
import type { AdapterProps, LexicalEditorProps, LexicalRichTextAdapterProvider } from './types.js'
import { getDefaultSanitizedEditorConfig } from './getDefaultSanitizedEditorConfig.js'
import { i18n } from './i18n.js'
import { defaultEditorConfig, defaultEditorFeatures } from './lexical/config/server/default.js'
import { loadFeatures } from './lexical/config/server/loader.js'
import { sanitizeServerFeatures } from './lexical/config/server/sanitize.js'
import { defaultEditorFeatures } from './lexical/config/server/default.js'
import { populateLexicalPopulationPromises } from './populateGraphQL/populateLexicalPopulationPromises.js'
import { featuresInputToEditorConfig } from './utilities/editorConfigFactory.js'
import { getGenerateImportMap } from './utilities/generateImportMap.js'
import { getGenerateSchemaMap } from './utilities/generateSchemaMap.js'
import { recurseNodeTree } from './utilities/recurseNodeTree.js'
@@ -34,7 +28,7 @@ let checkedDependencies = false
export const lexicalTargetVersion = '0.21.0'
export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapterProvider {
export function lexicalEditor(args?: LexicalEditorProps): LexicalRichTextAdapterProvider {
if (
process.env.NODE_ENV !== 'production' &&
process.env.PAYLOAD_DISABLE_DEPENDENCY_CHECKER !== 'true' &&
@@ -66,7 +60,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
let resolvedFeatureMap: ResolvedServerFeatureMap
let finalSanitizedEditorConfig: SanitizedServerEditorConfig // For server only
if (!props || (!props.features && !props.lexical)) {
if (!args || (!args.features && !args.lexical)) {
finalSanitizedEditorConfig = await getDefaultSanitizedEditorConfig({
config,
parentIsLocalized,
@@ -76,41 +70,16 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
resolvedFeatureMap = finalSanitizedEditorConfig.resolvedFeatureMap
} else {
if (props.features && typeof props.features === 'function') {
const rootEditor = config.editor
let rootEditorFeatures: FeatureProviderServer<unknown, unknown, unknown>[] = []
if (typeof rootEditor === 'object' && 'features' in rootEditor) {
rootEditorFeatures = (rootEditor as LexicalRichTextAdapter).features
}
features = props.features({
defaultFeatures: defaultEditorFeatures,
rootFeatures: rootEditorFeatures,
})
} else {
features = props.features as FeatureProviderServer<unknown, unknown, unknown>[]
}
if (!features) {
features = defaultEditorFeatures
}
const lexical = props.lexical ?? defaultEditorConfig.lexical
resolvedFeatureMap = await loadFeatures({
const result = await featuresInputToEditorConfig({
config,
features: args?.features,
isRoot,
lexical: args?.lexical,
parentIsLocalized,
unSanitizedEditorConfig: {
features,
lexical,
},
})
finalSanitizedEditorConfig = {
features: sanitizeServerFeatures(resolvedFeatureMap),
lexical: props.lexical,
resolvedFeatureMap,
}
finalSanitizedEditorConfig = result.sanitizedConfig
features = result.features
resolvedFeatureMap = result.resolvedFeatureMap
}
const featureI18n = finalSanitizedEditorConfig.features.i18n
@@ -128,7 +97,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
CellComponent: {
path: '@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell',
serverProps: {
admin: props?.admin,
admin: args?.admin,
sanitizedEditorConfig: finalSanitizedEditorConfig,
},
},
@@ -137,7 +106,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
FieldComponent: {
path: '@payloadcms/richtext-lexical/rsc#RscEntryLexicalField',
serverProps: {
admin: props?.admin,
admin: args?.admin,
sanitizedEditorConfig: finalSanitizedEditorConfig,
},
},
@@ -1055,6 +1024,7 @@ export type { LexicalEditorProps, LexicalFieldAdminProps, LexicalRichTextAdapter
export { createServerFeature } from './utilities/createServerFeature.js'
export { editorConfigFactory } from './utilities/editorConfigFactory.js'
export type { FieldsDrawerProps } from './utilities/fieldsDrawer/Drawer.js'
export { extractPropsFromJSXPropsString } from './utilities/jsx/extractPropsFromJSXPropsString.js'
export {
@@ -1063,4 +1033,5 @@ export {
objectToFrontmatter,
propsToJSXString,
} from './utilities/jsx/jsx.js'
export { upgradeLexicalData } from './utilities/upgradeLexicalData/index.js'

View File

@@ -38,9 +38,7 @@ export type LexicalFieldAdminClientProps = {
placeholder?: string
} & Omit<LexicalFieldAdminProps, 'placeholder'>
export type LexicalEditorProps = {
admin?: LexicalFieldAdminProps
features?:
export type FeaturesInput =
| (({
defaultFeatures,
rootFeatures,
@@ -72,6 +70,10 @@ export type LexicalEditorProps = {
rootFeatures: FeatureProviderServer<any, any, any>[]
}) => FeatureProviderServer<any, any, any>[])
| FeatureProviderServer<any, any, any>[]
export type LexicalEditorProps = {
admin?: LexicalFieldAdminProps
features?: FeaturesInput
lexical?: LexicalEditorConfig
}

View File

@@ -0,0 +1,140 @@
import type { EditorConfig as LexicalEditorConfig } from 'lexical'
import type { RichTextAdapterProvider, RichTextField, SanitizedConfig } from 'payload'
import type { FeatureProviderServer, ResolvedServerFeatureMap } from '../features/typesServer.js'
import type { SanitizedServerEditorConfig } from '../lexical/config/types.js'
import type {
FeaturesInput,
LexicalRichTextAdapter,
LexicalRichTextAdapterProvider,
} from '../types.js'
import { getDefaultSanitizedEditorConfig } from '../getDefaultSanitizedEditorConfig.js'
import { defaultEditorConfig, defaultEditorFeatures } from '../lexical/config/server/default.js'
import { loadFeatures } from '../lexical/config/server/loader.js'
import { sanitizeServerFeatures } from '../lexical/config/server/sanitize.js'
export const editorConfigFactory = {
default: async (args: {
config: SanitizedConfig
parentIsLocalized?: boolean
}): Promise<SanitizedServerEditorConfig> => {
return getDefaultSanitizedEditorConfig({
config: args.config,
parentIsLocalized: args.parentIsLocalized ?? false,
})
},
/**
* If you have instantiated a lexical editor and are accessing it outside a field (=> this is the unsanitized editor),
* you can extract the editor config from it.
* This is common if you define the editor in a re-usable module scope variable and pass it to the richText field.
*
* This is the least efficient way to get the editor config, and not recommended. It is recommended to extract the `features` arg
* into a separate variable and use `fromFeatures` instead.
*/
fromEditor: async (args: {
config: SanitizedConfig
editor: LexicalRichTextAdapterProvider
isRoot?: boolean
lexical?: LexicalEditorConfig
parentIsLocalized?: boolean
}): Promise<SanitizedServerEditorConfig> => {
const lexicalAdapter: LexicalRichTextAdapter = await args.editor({
config: args.config,
isRoot: args.isRoot ?? false,
parentIsLocalized: args.parentIsLocalized ?? false,
})
const sanitizedServerEditorConfig: SanitizedServerEditorConfig = lexicalAdapter.editorConfig
return sanitizedServerEditorConfig
},
/**
* Create a new editor config - behaves just like instantiating a new `lexicalEditor`
*/
fromFeatures: async (args: {
config: SanitizedConfig
features?: FeaturesInput
isRoot?: boolean
lexical?: LexicalEditorConfig
parentIsLocalized?: boolean
}): Promise<SanitizedServerEditorConfig> => {
return (await featuresInputToEditorConfig(args)).sanitizedConfig
},
fromField: (args: { field: RichTextField }): SanitizedServerEditorConfig => {
const lexicalAdapter: LexicalRichTextAdapter = args.field.editor as LexicalRichTextAdapter
const sanitizedServerEditorConfig: SanitizedServerEditorConfig = lexicalAdapter.editorConfig
return sanitizedServerEditorConfig
},
fromUnsanitizedField: async (args: {
config: SanitizedConfig
field: RichTextField
isRoot?: boolean
parentIsLocalized?: boolean
}): Promise<SanitizedServerEditorConfig> => {
const lexicalAdapterProvider: RichTextAdapterProvider = args.field
.editor as RichTextAdapterProvider
const lexicalAdapter: LexicalRichTextAdapter = (await lexicalAdapterProvider({
config: args.config,
isRoot: args.isRoot ?? false,
parentIsLocalized: args.parentIsLocalized ?? false,
})) as LexicalRichTextAdapter
const sanitizedServerEditorConfig: SanitizedServerEditorConfig = lexicalAdapter.editorConfig
return sanitizedServerEditorConfig
},
}
export const featuresInputToEditorConfig = async (args: {
config: SanitizedConfig
features?: FeaturesInput
isRoot?: boolean
lexical?: LexicalEditorConfig
parentIsLocalized?: boolean
}): Promise<{
features: FeatureProviderServer<unknown, unknown, unknown>[]
resolvedFeatureMap: ResolvedServerFeatureMap
sanitizedConfig: SanitizedServerEditorConfig
}> => {
let features: FeatureProviderServer<unknown, unknown, unknown>[] = []
if (args.features && typeof args.features === 'function') {
const rootEditor = args.config.editor
let rootEditorFeatures: FeatureProviderServer<unknown, unknown, unknown>[] = []
if (typeof rootEditor === 'object' && 'features' in rootEditor) {
rootEditorFeatures = (rootEditor as LexicalRichTextAdapter).features
}
features = args.features({
defaultFeatures: defaultEditorFeatures,
rootFeatures: rootEditorFeatures,
})
} else {
features = args.features as FeatureProviderServer<unknown, unknown, unknown>[]
}
if (!features) {
features = defaultEditorFeatures
}
const lexical = args.lexical ?? defaultEditorConfig.lexical
const resolvedFeatureMap = await loadFeatures({
config: args.config,
isRoot: args.isRoot ?? false,
parentIsLocalized: args.parentIsLocalized ?? false,
unSanitizedEditorConfig: {
features,
lexical,
},
})
return {
features,
resolvedFeatureMap,
sanitizedConfig: {
features: sanitizeServerFeatures(resolvedFeatureMap),
lexical: args.lexical,
resolvedFeatureMap,
},
}
}