Skip to content

Commit 0b2be54

Browse files
authored
feat(richtext-lexical): improve lexical types (#6928)
1 parent cae423f commit 0b2be54

File tree

28 files changed

+280
-116
lines changed

28 files changed

+280
-116
lines changed

docs/lexical/converters.mdx

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -46,35 +46,55 @@ const Pages: CollectionConfig = {
4646

4747
The `lexicalHTML()` function creates a new field that automatically converts the referenced lexical richText field into HTML through an afterRead hook.
4848

49-
### Generating HTML anywhere on the server:
49+
### Generating HTML anywhere on the server
5050

51-
If you wish to convert JSON to HTML ad-hoc, use this code snippet:
51+
If you wish to convert JSON to HTML ad-hoc, use the `convertLexicalToHTML` function:
5252

5353
```ts
54-
import type { SerializedEditorState } from 'lexical'
54+
import { consolidateHTMLConverters, convertLexicalToHTML } from '@payloadcms/richtext-lexical'
55+
56+
57+
await convertLexicalToHTML({
58+
converters: consolidateHTMLConverters({ editorConfig }),
59+
data: editorData,
60+
payload, // if you have payload but no req available, pass it in here to enable server-only functionality (e.g. proper conversion of upload nodes)
61+
req, // if you have req available, pass it in here to enable server-only functionality (e.g. proper conversion of upload nodes). No need to pass in payload if req is passed in.
62+
})
63+
```
64+
This method employs `convertLexicalToHTML` from `@payloadcms/richtext-lexical`, which converts the serialized editor state into HTML.
65+
66+
Because every `Feature` is able to provide html converters, and because the `htmlFeature` can modify those or provide their own, we need to consolidate them with the default html Converters using the `consolidateHTMLConverters` function.
67+
68+
#### Example: Generating HTML within an afterRead hook
69+
70+
```ts
71+
import type { FieldHook } from 'payload'
72+
5573
import {
56-
type SanitizedEditorConfig,
57-
convertLexicalToHTML,
74+
HTMLConverterFeature,
5875
consolidateHTMLConverters,
76+
convertLexicalToHTML,
77+
defaultEditorConfig,
78+
defaultEditorFeatures,
79+
sanitizeServerEditorConfig,
5980
} from '@payloadcms/richtext-lexical'
6081

61-
async function lexicalToHTML(
62-
editorData: SerializedEditorState,
63-
editorConfig: SanitizedEditorConfig,
64-
) {
65-
return await convertLexicalToHTML({
66-
converters: consolidateHTMLConverters({ editorConfig }),
67-
data: editorData,
68-
payload, // if you have payload but no req available, pass it in here to enable server-only functionality (e.g. proper conversion of upload nodes)
69-
req, // if you have req available, pass it in here to enable server-only functionality (e.g. proper conversion of upload nodes). No need to pass in payload if req is passed in.
82+
const hook: FieldHook = async ({ req, siblingData }) => {
83+
const editorConfig = defaultEditorConfig
84+
85+
editorConfig.features = [...defaultEditorFeatures, HTMLConverterFeature({})]
86+
87+
const sanitizedEditorConfig = await sanitizeServerEditorConfig(editorConfig, req.payload.config)
88+
89+
const html = await convertLexicalToHTML({
90+
converters: consolidateHTMLConverters({ editorConfig: sanitizedEditorConfig }),
91+
data: siblingData.lexicalSimple,
92+
req,
7093
})
94+
return html
7195
}
7296
```
7397

74-
This method employs `convertLexicalToHTML` from `@payloadcms/richtext-lexical`, which converts the serialized editor state into HTML.
75-
76-
Because every `Feature` is able to provide html converters, and because the `htmlFeature` can modify those or provide their own, we need to consolidate them with the default html Converters using the `consolidateHTMLConverters` function.
77-
7898
### CSS
7999

80100
Payload's lexical HTML converter does not generate CSS for you, but it does add classes to the generated HTML. You can use these classes to style the HTML in your frontend.
@@ -184,10 +204,11 @@ import { createHeadlessEditor } from '@lexical/headless' // <= make sure this pa
184204
import { getEnabledNodes, sanitizeServerEditorConfig } from '@payloadcms/richtext-lexical'
185205

186206
const yourEditorConfig // <= your editor config here
207+
const payloadConfig // <= your payload config here
187208

188209
const headlessEditor = createHeadlessEditor({
189210
nodes: getEnabledNodes({
190-
editorConfig: sanitizeServerEditorConfig(yourEditorConfig),
211+
editorConfig: sanitizeServerEditorConfig(yourEditorConfig, payloadConfig),
191212
}),
192213
})
193214
```
@@ -316,7 +337,7 @@ Convert markdown content to the Lexical editor format with the following:
316337
import { $convertFromMarkdownString } from '@lexical/markdown'
317338
import { sanitizeServerEditorConfig } from '@payloadcms/richtext-lexical'
318339

319-
const yourSanitizedEditorConfig = sanitizeServerEditorConfig(yourEditorConfig) // <= your editor config here
340+
const yourSanitizedEditorConfig = sanitizeServerEditorConfig(yourEditorConfig, payloadConfig) // <= your editor config & payload config here
320341
const markdown = `# Hello World`
321342

322343
headlessEditor.update(
@@ -344,7 +365,7 @@ import { $convertToMarkdownString } from '@lexical/markdown'
344365
import { sanitizeServerEditorConfig } from '@payloadcms/richtext-lexical'
345366
import type { SerializedEditorState } from 'lexical'
346367

347-
const yourSanitizedEditorConfig = sanitizeServerEditorConfig(yourEditorConfig) // <= your editor config here
368+
const yourSanitizedEditorConfig = sanitizeServerEditorConfig(yourEditorConfig, payloadConfig) // <= your editor config & payload config here
348369
const yourEditorState: SerializedEditorState // <= your current editor state here
349370

350371
// Import editor state into your headless editor

docs/lexical/overview.mdx

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ import { CallToAction } from '../blocks/CallToAction'
8989

9090
{
9191
editor: lexicalEditor({
92-
features: ({ defaultFeatures }) => [
92+
features: ({ defaultFeatures, rootFeatures }) => [
9393
...defaultFeatures,
9494
LinkFeature({
9595
// Example showing how to customize the built-in fields
@@ -134,6 +134,15 @@ import { CallToAction } from '../blocks/CallToAction'
134134
}
135135
```
136136

137+
`features` can be both an array of features, or a function returning an array of features. The function provides the following props:
138+
139+
140+
| Prop | Description |
141+
|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
142+
| **`defaultFeatures`** | This opinionated array contains all "recommended" default features. You can see which features are included in the default features in the table below. |
143+
| **`rootFeatures`** | This array contains all features that are enabled in the root richText editor (the one defined in the payload.config.ts). If this field is the root richText editor, or if the root richText editor is not a lexical editor, this array will be empty. |
144+
145+
137146
## Features overview
138147

139148
Here's an overview of all the included features:
@@ -169,3 +178,77 @@ Notice how even the toolbars are features? That's how extensible our lexical edi
169178
## Creating your own, custom Feature
170179

171180
You can find more information about creating your own feature in our [building custom feature docs](lexical/building-custom-features).
181+
182+
## TypeScript
183+
184+
Every single piece of saved data is 100% fully-typed within lexical. It provides a type for every single node, which can be imported from `@payloadcms/richtext-lexical` - each type is prefixed with `Serialized`, e.g. `SerializedUploadNode`.
185+
186+
In order to fully type the entire editor JSON, you can use our `TypedEditorState` helper type, which accepts a union of all possible node types as a generic. The reason we do not provide a type which already contains all possible node types is because the possible node types depend on which features you have enabled in your editor. Here is an example:
187+
188+
```ts
189+
import type {
190+
SerializedAutoLinkNode,
191+
SerializedBlockNode,
192+
SerializedHorizontalRuleNode,
193+
SerializedLinkNode,
194+
SerializedListItemNode,
195+
SerializedListNode,
196+
SerializedParagraphNode,
197+
SerializedQuoteNode,
198+
SerializedRelationshipNode,
199+
SerializedTextNode,
200+
SerializedUploadNode,
201+
TypedEditorState,
202+
} from '@payloadcms/richtext-lexical'
203+
204+
const editorState: TypedEditorState<
205+
| SerializedAutoLinkNode
206+
| SerializedBlockNode
207+
| SerializedHorizontalRuleNode
208+
| SerializedLinkNode
209+
| SerializedListItemNode
210+
| SerializedListNode
211+
| SerializedParagraphNode
212+
| SerializedQuoteNode
213+
| SerializedRelationshipNode
214+
| SerializedTextNode
215+
| SerializedUploadNode
216+
> = {
217+
root: {
218+
type: 'root',
219+
direction: 'ltr',
220+
format: '',
221+
indent: 0,
222+
version: 1,
223+
children: [
224+
{
225+
children: [
226+
{
227+
detail: 0,
228+
format: 0,
229+
mode: 'normal',
230+
style: '',
231+
text: 'Some text. Every property here is fully-typed',
232+
type: 'text',
233+
version: 1,
234+
},
235+
],
236+
direction: 'ltr',
237+
format: '',
238+
indent: 0,
239+
type: 'paragraph',
240+
textFormat: 0,
241+
version: 1,
242+
},
243+
],
244+
},
245+
}
246+
```
247+
248+
This is a type-safe representation of the editor state. Looking at the auto-suggestions of `type` it will show you all the possible node types you can use.
249+
250+
Make sure to only use types exported from `@payloadcms/richtext-lexical`, not from the lexical core packages. We only have control over types we export and can guarantee that those are correct, even though lexical core may export types with identical names.
251+
252+
### Automatic type generation
253+
254+
Lexical does not generate the accurate type definitions for your richText fields for you yet - this will be improved in the future. Currently, it only outputs the rough shape of the editor JSON which you can enhance using type assertions.

packages/richtext-lexical/src/features/blockquote/feature.server.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import type { SerializedQuoteNode as _SerializedQuoteNode } from '@lexical/rich-text'
2+
import type { Spread } from 'lexical'
3+
14
import { QuoteNode } from '@lexical/rich-text'
25

36
// eslint-disable-next-line payload/no-imports-from-exports-dir
@@ -8,6 +11,13 @@ import { createNode } from '../typeUtilities.js'
811
import { i18n } from './i18n.js'
912
import { MarkdownTransformer } from './markdownTransformer.js'
1013

14+
export type SerializedQuoteNode = Spread<
15+
{
16+
type: 'quote'
17+
},
18+
_SerializedQuoteNode
19+
>
20+
1121
export const BlockquoteFeature = createServerFeature({
1222
feature: {
1323
ClientFeature: BlockquoteFeatureClient,

packages/richtext-lexical/src/features/blocks/nodes/BlocksNode.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ const BlockComponent = React.lazy(() =>
3030

3131
export type SerializedBlockNode = Spread<
3232
{
33+
children?: never // required so that our typed editor state doesn't automatically add children
3334
fields: BlockFields
35+
type: 'block'
3436
},
3537
SerializedDecoratorBlockNode
3638
>
@@ -102,7 +104,7 @@ export class BlockNode extends DecoratorBlockNode {
102104
exportJSON(): SerializedBlockNode {
103105
return {
104106
...super.exportJSON(),
105-
type: this.getType(),
107+
type: 'block',
106108
fields: this.getFields(),
107109
version: 2,
108110
}

packages/richtext-lexical/src/features/converters/html/feature.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export type HTMLConverterFeatureProps = {
99
}
1010

1111
// This is just used to save the props on the richText field
12-
export const HTMLConverterFeature = createServerFeature({
12+
export const HTMLConverterFeature = createServerFeature<HTMLConverterFeatureProps>({
1313
feature: {},
1414
key: 'htmlConverter',
1515
})

packages/richtext-lexical/src/features/horizontalRule/nodes/HorizontalRuleNode.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
LexicalCommand,
77
LexicalNode,
88
SerializedLexicalNode,
9+
Spread,
910
} from 'lexical'
1011

1112
import { addClassNamesToElement } from '@lexical/utils'
@@ -21,7 +22,13 @@ const HorizontalRuleComponent = React.lazy(() =>
2122
/**
2223
* Serialized representation of a horizontal rule node. Serialized = converted to JSON. This is what is stored in the database / in the lexical editor state.
2324
*/
24-
export type SerializedHorizontalRuleNode = SerializedLexicalNode
25+
export type SerializedHorizontalRuleNode = Spread<
26+
{
27+
children?: never // required so that our typed editor state doesn't automatically add children
28+
type: 'horizontalrule'
29+
},
30+
SerializedLexicalNode
31+
>
2532

2633
export const INSERT_HORIZONTAL_RULE_COMMAND: LexicalCommand<void> = createCommand(
2734
'INSERT_HORIZONTAL_RULE_COMMAND',

packages/richtext-lexical/src/features/link/nodes/AutoLinkNode.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,16 @@ export class AutoLinkNode extends LinkNode {
4141
return node
4242
}
4343

44+
// @ts-expect-error
4445
exportJSON(): SerializedAutoLinkNode {
46+
const serialized = super.exportJSON()
4547
return {
46-
...super.exportJSON(),
4748
type: 'autolink',
49+
children: serialized.children,
50+
direction: serialized.direction,
51+
fields: serialized.fields,
52+
format: serialized.format,
53+
indent: serialized.indent,
4854
version: 2,
4955
}
5056
}

packages/richtext-lexical/src/features/link/nodes/LinkNode.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export class LinkNode extends ElementNode {
129129
exportJSON(): SerializedLinkNode {
130130
const returnObject: SerializedLinkNode = {
131131
...super.exportJSON(),
132-
type: this.getType(),
132+
type: 'link',
133133
fields: this.getFields(),
134134
version: 3,
135135
}

packages/richtext-lexical/src/features/link/nodes/types.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { SerializedElementNode, Spread } from 'lexical'
1+
import type { SerializedElementNode, SerializedLexicalNode, Spread } from 'lexical'
22

33
export type LinkFields = {
44
// unknown, custom fields:
@@ -18,11 +18,14 @@ export type LinkFields = {
1818
url: string
1919
}
2020

21-
export type SerializedLinkNode = Spread<
21+
export type SerializedLinkNode<T extends SerializedLexicalNode = SerializedLexicalNode> = Spread<
2222
{
2323
fields: LinkFields
2424
id?: string // optional if AutoLinkNode
25+
type: 'link'
2526
},
26-
SerializedElementNode
27+
SerializedElementNode<T>
2728
>
28-
export type SerializedAutoLinkNode = Omit<SerializedLinkNode, 'id'>
29+
export type SerializedAutoLinkNode<T extends SerializedLexicalNode = SerializedLexicalNode> = {
30+
type: 'autolink'
31+
} & Omit<SerializedLinkNode<T>, 'id' | 'type'>

packages/richtext-lexical/src/features/lists/checklist/feature.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const ChecklistFeature = createServerFeature({
2626
}),
2727
createNode({
2828
converters: {
29-
html: ListItemHTMLConverter,
29+
html: ListItemHTMLConverter as any,
3030
},
3131
node: ListItemNode,
3232
}),

0 commit comments

Comments
 (0)