docs(richtext-lexical): docs for building custom lexical features (#6862)
This commit is contained in:
@@ -26,7 +26,8 @@ Right now, Payload is officially supporting two rich text editors:
|
||||
2. [Lexical](/docs/rich-text/lexical) - beta, where things will be moving
|
||||
|
||||
<Banner type="success">
|
||||
<strong>Consistent with Payload's goal of making you learn as little of Payload as possible, customizing
|
||||
<strong>
|
||||
Consistent with Payload's goal of making you learn as little of Payload as possible, customizing
|
||||
and using the Rich Text Editor does not involve learning how to develop for a <em>Payload</em>{' '}
|
||||
rich text editor.
|
||||
</strong>
|
||||
|
||||
620
docs/lexical/building-custom-features.mdx
Normal file
620
docs/lexical/building-custom-features.mdx
Normal file
@@ -0,0 +1,620 @@
|
||||
---
|
||||
title: Lexical Building Custom Features
|
||||
label: Custom Features
|
||||
order: 40
|
||||
desc: Building custom lexical features
|
||||
keywords: lexical, rich text, editor, headless cms, feature, features
|
||||
---
|
||||
|
||||
Before you begin building custom features for Lexical, it is crucial to familiarize yourself with the [Lexical docs](https://lexical.dev/docs/intro), particularly the "Concepts" section. This foundation is necessary for understanding Lexical's core principles, such as nodes, editor state, and commands.
|
||||
|
||||
Lexical features are designed to be modular, meaning each piece of functionality is encapsulated within just two specific interfaces: one for server-side code and one for client-side code.
|
||||
|
||||
By convention, these are named feature.server.ts for server-side functionality and feature.client.ts for client-side functionality. The primary functionality is housed within feature.server.ts, which users will import into their projects. The client-side feature, although defined separately, is integrated and rendered server-side through the server feature. That way, we still maintain a clear boundary between server and client code, while also centralizing the code needed for a feature in basically one place. This approach is beneficial for managing all the bits and pieces which make up your feature as a whole, such as toolbar entries, buttons, or new nodes, allowing each feature to be neatly contained and managed independently.
|
||||
|
||||
|
||||
## Server Feature
|
||||
|
||||
In order to get started with a new feature, you should start with the server feature which is the entry-point of your feature.
|
||||
|
||||
**Example myFeature/feature.server.ts:**
|
||||
|
||||
```ts
|
||||
import { createServerFeature } from '@payloadcms/richtext-lexical';
|
||||
|
||||
export const MyFeature = createServerFeature({
|
||||
feature: {
|
||||
},
|
||||
key: 'myFeature',
|
||||
})
|
||||
```
|
||||
|
||||
`createServerFeature` is a helper function which lets you create new features without boilerplate code.
|
||||
|
||||
Now, the feature is ready to be used in the editor:
|
||||
|
||||
```ts
|
||||
import { MyFeature } from './myFeature/feature.server';
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical';
|
||||
|
||||
//...
|
||||
{
|
||||
name: 'richText',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: [
|
||||
MyFeature(),
|
||||
],
|
||||
}),
|
||||
},
|
||||
```
|
||||
|
||||
By default, this server feature does nothing - you haven't added any functionality yet. Depending on what you want your
|
||||
feature to do, the ServerFeature type exposes various properties you can set to inject custom functionality into the lexical editor.
|
||||
|
||||
Here is an example:
|
||||
|
||||
```ts
|
||||
import { createServerFeature, createNode } from '@payloadcms/richtext-lexical';
|
||||
import { MyClientFeature } from './feature.client.ts';
|
||||
import { MyMarkdownTransformer } from './feature.client.ts';
|
||||
|
||||
export const MyFeature = createServerFeature({
|
||||
feature: {
|
||||
// This allows you to connect the Client Feature. More on that below
|
||||
ClientFeature: MyClientFeature,
|
||||
// This allows you to add i18n translations scoped to your feature.
|
||||
// This specific translation will be available under "lexical:myFeature:label" - myFeature
|
||||
// being your feature key.
|
||||
i18n: {
|
||||
en: {
|
||||
label: 'My Feature',
|
||||
},
|
||||
de: {
|
||||
label: 'Mein Feature',
|
||||
},
|
||||
},
|
||||
// Markdown Transformers in the server feature are used when converting the
|
||||
// editor from or to markdown
|
||||
markdownTransformers: [MyMarkdownTransformer],
|
||||
nodes: [
|
||||
// Use the createNode helper function to more easily create nodes with proper typing
|
||||
createNode({
|
||||
converters: {
|
||||
html: {
|
||||
converter: () => {
|
||||
return `<hr/>`
|
||||
},
|
||||
nodeTypes: [MyNode.getType()],
|
||||
},
|
||||
},
|
||||
// Here you can add your actual node. On the server, they will be
|
||||
// used to initialize a headless editor which can be used to perform
|
||||
// operations on the editor, like markdown / html conversion.
|
||||
node: MyNode,
|
||||
}),
|
||||
],
|
||||
},
|
||||
key: 'myFeature',
|
||||
})
|
||||
```
|
||||
|
||||
### Feature load order
|
||||
|
||||
Server features can also accept a function as the `feature` property (useful for sanitizing props, as mentioned below). This function will be called when the feature is loaded during the payload sanitization process:
|
||||
|
||||
```ts
|
||||
import { createServerFeature } from '@payloadcms/richtext-lexical';
|
||||
|
||||
createServerFeature({
|
||||
//...
|
||||
feature: async ({ config, isRoot, props, resolvedFeatures, unSanitizedEditorConfig, featureProviderMap }) => {
|
||||
|
||||
return {
|
||||
//Actual server feature here...
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
"Loading" here means the process of calling this `feature` function. By default, features are called in the order in which they are added to the editor.
|
||||
However, sometimes you might want to load a feature after another feature has been loaded, or require a different feature to be loaded, throwing an error if this is not the case.
|
||||
|
||||
Within lexical, one example where this is done are our list features. Both `UnorderedListFeature` and `OrderedListFeature` register the same `ListItem` node. Within `UnorderedListFeature` we register it normally, but within `OrderedListFeature` we want to only register the `ListItem` node if the `UnorderedListFeature` is not present - otherwise, we would have two features registering the same node.
|
||||
|
||||
Here is how we do it:
|
||||
|
||||
```ts
|
||||
import { createServerFeature, createNode } from '@payloadcms/richtext-lexical';
|
||||
|
||||
export const OrderedListFeature = createServerFeature({
|
||||
feature: ({ featureProviderMap }) => {
|
||||
return {
|
||||
// ...
|
||||
nodes: featureProviderMap.has('unorderedList')
|
||||
? []
|
||||
: [
|
||||
createNode({
|
||||
// ...
|
||||
}),
|
||||
],
|
||||
}
|
||||
},
|
||||
key: 'orderedList',
|
||||
})
|
||||
```
|
||||
|
||||
`featureProviderMap` will always be available and contain all the features, even yet-to-be-loaded ones, so we can check if a feature is loaded by checking if its `key` present in the map.
|
||||
|
||||
If you wanted to make sure a feature is loaded before another feature, you can use the `dependenciesPriority` property:
|
||||
|
||||
```ts
|
||||
import { createServerFeature } from '@payloadcms/richtext-lexical';
|
||||
|
||||
export const MyFeature = createServerFeature({
|
||||
feature: ({ featureProviderMap }) => {
|
||||
return {
|
||||
// ...
|
||||
}
|
||||
},
|
||||
key: 'myFeature',
|
||||
dependenciesPriority: ['otherFeature'],
|
||||
})
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **`dependenciesSoft`** | Keys of soft-dependencies needed for this feature. These are optional. Payload will attempt to load them before this feature, but doesn't throw an error if that's not possible. |
|
||||
| **`dependencies`** | Keys of dependencies needed for this feature. These dependencies do not have to be loaded first, but they have to exist, otherwise an error will be thrown. |
|
||||
| **`dependenciesPriority`** | Keys of priority dependencies needed for this feature. These dependencies have to be loaded first AND have to exist, otherwise an error will be thrown. They will be available in the `feature` property. |
|
||||
|
||||
## Client Feature
|
||||
|
||||
Most of the functionality which the user actually sees and interacts with, like toolbar items and React components for nodes, resides on the client-side.
|
||||
|
||||
To set up your client-side feature, follow these three steps:
|
||||
|
||||
1. **Create a Separate File**: Start by creating a new file specifically for your client feature, such as `myFeature/feature.client.ts`. It's important to keep client and server features in separate files to maintain a clean boundary between server and client code.
|
||||
2. **'use client'**: Mark that file with a 'use client' directive at the top of the file
|
||||
3. **Register the Client Feature**: Register the client feature within your server feature, by passing it to the `ClientFeature` prop. This is needed because the server feature is the sole entry-point of your feature. This also means you are not able to create a client feature without a server feature, as you will not be able to register it otherwise.
|
||||
|
||||
**Example myFeature/feature.client.ts:**
|
||||
|
||||
```ts
|
||||
'use client'
|
||||
|
||||
import { createClientFeature } from '@payloadcms/richtext-lexical/client';
|
||||
|
||||
export const MyClientFeature = createClientFeature({
|
||||
|
||||
})
|
||||
```
|
||||
|
||||
Explore the APIs available through ClientFeature to add the specific functionality you need. Remember, do not import directly from `'@payloadcms/richtext-lexical'` when working on the client-side, as it will cause errors with webpack or turbopack. Instead, use `'@payloadcms/richtext-lexical/client'` for all client-side imports. Type-imports are excluded from this rule and can always be imported.
|
||||
|
||||
### Nodes
|
||||
|
||||
Add nodes to the `nodes` array in **both** your client & server feature. On the server side, nodes are utilized for backend operations like HTML conversion in a headless editor. On the client side, these nodes are integral to how content is displayed and managed in the editor, influencing how they are rendered, behave, and saved in the database.
|
||||
|
||||
Example:
|
||||
|
||||
**myFeature/feature.client.ts:**
|
||||
|
||||
```ts
|
||||
'use client'
|
||||
|
||||
import { createClientFeature } from '@payloadcms/richtext-lexical/client';
|
||||
import { MyNode } from './nodes/MyNode';
|
||||
|
||||
export const MyClientFeature = createClientFeature({
|
||||
nodes: [MyNode]
|
||||
})
|
||||
```
|
||||
|
||||
**myFeature/nodes/MyNode.tsx:**
|
||||
|
||||
Here is a basic DecoratorNode example:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
DOMExportOutput,
|
||||
EditorConfig,
|
||||
LexicalNode,
|
||||
SerializedLexicalNode,
|
||||
} from 'lexical'
|
||||
|
||||
import { $applyNodeReplacement, DecoratorNode } from 'lexical'
|
||||
|
||||
// SerializedLexicalNode is the default lexical node.
|
||||
// By setting your SerializedMyNode type to SerializedLexicalNode,
|
||||
// you are basically saying that this node does not save any additional data.
|
||||
// If you want your node to save data, feel free to extend it
|
||||
export type SerializedMyNode = SerializedLexicalNode
|
||||
|
||||
// Lazy-import the React component to your node here
|
||||
const MyNodeComponent = React.lazy(() =>
|
||||
import('../component/index.js').then((module) => ({
|
||||
default: module.MyNodeComponent,
|
||||
})),
|
||||
)
|
||||
|
||||
/**
|
||||
* This node is a DecoratorNode. DecoratorNodes allow you to render React components in the editor.
|
||||
*
|
||||
* They need both createDom and decorate functions. createDom => outside of the html. decorate => React Component inside of the html.
|
||||
*
|
||||
* If we used DecoratorBlockNode instead, we would only need a decorate method
|
||||
*/
|
||||
export class MyNode extends DecoratorNode<React.ReactElement> {
|
||||
static clone(node: MyNode): MyNode {
|
||||
return new MyNode(node.__key)
|
||||
}
|
||||
|
||||
static getType(): string {
|
||||
return 'myNode'
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines what happens if you copy a div element from another page and paste it into the lexical editor
|
||||
*
|
||||
* This also determines the behavior of lexical's internal HTML -> Lexical converter
|
||||
*/
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
div: () => ({
|
||||
conversion: $yourConversionMethod,
|
||||
priority: 0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The data for this node is stored serialized as JSON. This is the "load function" of that node: it takes the saved data and converts it into a node.
|
||||
*/
|
||||
static importJSON(serializedNode: SerializedMyNode): MyNode {
|
||||
return $createMyNode()
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines how the hr element is rendered in the lexical editor. This is only the "initial" / "outer" HTML element.
|
||||
*/
|
||||
createDOM(config: EditorConfig): HTMLElement {
|
||||
const element = document.createElement('div')
|
||||
return element
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows you to render a React component within whatever createDOM returns.
|
||||
*/
|
||||
decorate(): React.ReactElement {
|
||||
return <MyNodeComponent nodeKey={this.__key} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Opposite of importDOM, this function defines what happens when you copy a div element from the lexical editor and paste it into another page.
|
||||
*
|
||||
* This also determines the behavior of lexical's internal Lexical -> HTML converter
|
||||
*/
|
||||
exportDOM(): DOMExportOutput {
|
||||
return { element: document.createElement('div') }
|
||||
}
|
||||
/**
|
||||
* Opposite of importJSON. This determines what data is saved in the database / in the lexical editor state.
|
||||
*/
|
||||
exportJSON(): SerializedLexicalNode {
|
||||
return {
|
||||
type: 'myNode',
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
getTextContent(): string {
|
||||
return '\n'
|
||||
}
|
||||
|
||||
isInline(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
updateDOM(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// This is used in the importDOM method. Totally optional if you do not want your node to be created automatically when copy & pasting certain dom elements
|
||||
// into your editor.
|
||||
function $yourConversionMethod(): DOMConversionOutput {
|
||||
return { node: $createMyNode() }
|
||||
}
|
||||
|
||||
// This is a utility method to create a new MyNode. Utility methods prefixed with $ make it explicit that this should only be used within lexical
|
||||
export function $createMyNode(): MyNode {
|
||||
return $applyNodeReplacement(new MyNode())
|
||||
}
|
||||
|
||||
// This is just a utility method you can use to check if a node is a MyNode. This also ensures correct typing.
|
||||
export function $isMyNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is MyNode {
|
||||
return node instanceof MyNode
|
||||
}
|
||||
```
|
||||
|
||||
Please do not add any 'use client' directives to your nodes, as the node class can be used on the server.
|
||||
|
||||
### Plugins
|
||||
|
||||
One small part of a feature are plugins. The name stems from the lexical playground plugins and is just a small part of a lexical feature.
|
||||
Plugins are simply react components which are added to the editor, within all the lexical context providers. They can be used to add any functionality
|
||||
to the editor, by utilizing the lexical API.
|
||||
|
||||
Most commonly, they are used to register [lexical listeners](https://lexical.dev/docs/concepts/listeners), [node transforms](https://lexical.dev/docs/concepts/transforms) or [commands](https://lexical.dev/docs/concepts/commands).
|
||||
For example, you could add a drawer to your plugin and register a command which opens it. That command can then be called from anywhere within lexical, e.g. from within your custom lexical node.
|
||||
|
||||
To add a plugin, simply add it to the `plugins` array in your client feature:
|
||||
|
||||
```ts
|
||||
'use client'
|
||||
|
||||
import { createClientFeature } from '@payloadcms/richtext-lexical/client';
|
||||
import { MyPlugin } from './plugin';
|
||||
|
||||
export const MyClientFeature = createClientFeature({
|
||||
plugins: [MyPlugin]
|
||||
})
|
||||
```
|
||||
|
||||
Example plugin.tsx:
|
||||
|
||||
```ts
|
||||
'use client'
|
||||
import type {
|
||||
LexicalCommand,
|
||||
} from 'lexical'
|
||||
|
||||
import {
|
||||
createCommand,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_EDITOR
|
||||
} from 'lexical'
|
||||
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
|
||||
import { $insertNodeToNearestRoot } from '@lexical/utils'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import type { PluginComponent } from '@payloadcms/richtext-lexical' // type imports can be imported from @payloadcms/richtext-lexical - even on the client
|
||||
|
||||
import {
|
||||
$createMyNode,
|
||||
} from '../nodes/MyNode'
|
||||
import './index.scss'
|
||||
|
||||
export const INSERT_MYNODE_COMMAND: LexicalCommand<void> = createCommand(
|
||||
'INSERT_MYNODE_COMMAND',
|
||||
)
|
||||
|
||||
/**
|
||||
* Plugin which registers a lexical command to insert a new MyNode into the editor
|
||||
*/
|
||||
export const MyNodePlugin: PluginComponent= () => {
|
||||
// The useLexicalComposerContext hook can be used to access the lexical editor instance
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
INSERT_MYNODE_COMMAND,
|
||||
(type) => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const focusNode = selection.focus.getNode()
|
||||
|
||||
if (focusNode !== null) {
|
||||
const newMyNode = $createMyNode()
|
||||
$insertNodeToNearestRoot(newMyNode)
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
In this example, we register a lexical command which simply inserts a new MyNode into the editor. This command can be called from anywhere within lexical, e.g. from within a custom node.
|
||||
|
||||
### Toolbar items
|
||||
|
||||
Custom nodes and features on its own are pointless, if they can not be added to the editor. You will need to hook in one of our interfaces which allow the user to interact with the editor:
|
||||
|
||||
- Fixed toolbar which stays fixed at the top of the editor
|
||||
- Inline, floating toolbar which appears when selecting text
|
||||
- Slash menu which appears when typing `/` in the editor
|
||||
- Markdown transformers which are triggered when a certain text pattern is typed in the editor
|
||||
- Or any other interfaces which can be added via your own plugins. Our toolbars are a prime example of this - they are just plugins.
|
||||
|
||||
In order to add a toolbar item to either the floating or the inline toolbar, you can add a ToolbarGroup with a ToolbarItem to the `toolbarFixed` or `toolbarInline` props of your client feature:
|
||||
|
||||
```ts
|
||||
'use client'
|
||||
|
||||
import { createClientFeature, toolbarAddDropdownGroupWithItems } from '@payloadcms/richtext-lexical/client';
|
||||
import { IconComponent } from './icon';
|
||||
import { $isHorizontalRuleNode } from './nodes/MyNode';
|
||||
import { INSERT_MYNODE_COMMAND } from './plugin';
|
||||
import { $isNodeSelection } from 'lexical'
|
||||
|
||||
export const MyClientFeature = createClientFeature({
|
||||
toolbarFixed: {
|
||||
groups: [
|
||||
toolbarAddDropdownGroupWithItems([
|
||||
{
|
||||
ChildComponent: IconComponent,
|
||||
isActive: ({ selection }) => {
|
||||
if (!$isNodeSelection(selection) || !selection.getNodes().length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const firstNode = selection.getNodes()[0]
|
||||
return $isHorizontalRuleNode(firstNode)
|
||||
},
|
||||
key: 'myNode',
|
||||
label: ({ i18n }) => {
|
||||
return i18n.t('lexical:myFeature:label')
|
||||
},
|
||||
onSelect: ({ editor }) => {
|
||||
editor.dispatchCommand(INSERT_MYNODE_COMMAND, undefined)
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
You will have to provide a toolbar group first, and then the items for that toolbar group.
|
||||
We already export all the default toolbar groups (like `toolbarAddDropdownGroupWithItems`, so you can use them as a base for your own toolbar items.
|
||||
|
||||
If a toolbar with the same key is declared twice, all its items will be merged together into one group.
|
||||
|
||||
A `ToolbarItem` various props you can use to customize its behavior:
|
||||
|
||||
| Option | Description |
|
||||
|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **`ChildComponent`** | A React component which is rendered within your toolbar item's default button component. Usually, you want this to be an icon. |
|
||||
| **`Component`** | A React component which is rendered in place of the toolbar item's default button component, thus completely replacing it. The `ChildComponent` and `onSelect` properties will be ignored. |
|
||||
| **`label`** | The label will be displayed in your toolbar item, if it's within a dropdown group. In order to make use of i18n, this can be a function. |
|
||||
| **`key`** | Each toolbar item needs to have a unique key. |
|
||||
| **`onSelect`** | A function which is called when the toolbar item is clicked. |
|
||||
| **`isEnabled`** | This is optional and controls if the toolbar item is clickable or not. If `false` is returned here, it will be grayed out and unclickable. |
|
||||
| **`isActive`** | This is optional and controls if the toolbar item is highlighted or not |
|
||||
|
||||
The API for adding an item to the floating inline toolbar (`toolbarInline`) is identical. If you wanted to add an item to both the fixed and inline toolbar, you can extract it into its own variable
|
||||
(typed as `ToolbarGroup[]`) and add it to both the `toolbarFixed` and `toolbarInline` props.
|
||||
|
||||
### Slash Menu items
|
||||
|
||||
The API for adding items to the slash menu is similar. There are slash menu groups, and each slash menu groups has items. Here is an example:
|
||||
|
||||
```ts
|
||||
'use client'
|
||||
|
||||
import { createClientFeature, slashMenuBasicGroupWithItems } from '@payloadcms/richtext-lexical/client';
|
||||
import { INSERT_MYNODE_COMMAND } from './plugin';
|
||||
import { IconComponent } from './icon';
|
||||
|
||||
export const MyClientFeature = createClientFeature({
|
||||
slashMenu: {
|
||||
groups: [
|
||||
slashMenuBasicGroupWithItems([
|
||||
{
|
||||
Icon: IconComponent,
|
||||
key: 'myNode',
|
||||
keywords: ['myNode', 'myFeature', 'someOtherKeyword'],
|
||||
label: ({ i18n }) => {
|
||||
return i18n.t('lexical:myFeature:label')
|
||||
},
|
||||
onSelect: ({ editor }) => {
|
||||
editor.dispatchCommand(INSERT_MYNODE_COMMAND, undefined)
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **`Icon`** | The icon which is rendered in your slash menu item. |
|
||||
| **`label`** | The label will be displayed in your slash menu item. In order to make use of i18n, this can be a function. |
|
||||
| **`key`** | Each slash menu item needs to have a unique key. The key will be matched when typing, displayed if no `label` property is set, and used for classNames. |
|
||||
| **`onSelect`** | A function which is called when the slash menu item is selected. |
|
||||
| **`keywords`** | Keywords are used in order to match the item for different texts typed after the '/'. E.g. you might want to show a horizontal rule item if you type both /hr, /separator, /horizontal etc. Additionally to the keywords, the label and key will be used to match the correct slash menu item. |
|
||||
|
||||
## Props
|
||||
|
||||
In order to accept props in your feature, you should first type them as a generic.
|
||||
|
||||
Server Feature:
|
||||
|
||||
```ts
|
||||
createServerFeature<UnSanitizedProps, SanitizedProps, UnSanitizedClientProps>({
|
||||
//...
|
||||
})
|
||||
```
|
||||
|
||||
Client Feature:
|
||||
|
||||
```ts
|
||||
createClientFeature<UnSanitizedClientProps, SanitizedClientProps>({
|
||||
//...
|
||||
})
|
||||
```
|
||||
|
||||
The unSanitized props are what the user will pass to the feature when they call its provider function and add it to their editor config. You then have an option to sanitize those props.
|
||||
To sanitize those in the server feature, you can pass a function to `feature` instead of an object:
|
||||
|
||||
```ts
|
||||
createServerFeature<UnSanitizedProps, SanitizedProps, UnSanitizedClientProps>({
|
||||
//...
|
||||
feature: async ({ config, isRoot, props, resolvedFeatures, unSanitizedEditorConfig, featureProviderMap }) => {
|
||||
const sanitizedProps = doSomethingWithProps(props)
|
||||
|
||||
return {
|
||||
sanitizedServerFeatureProps: sanitizedProps,
|
||||
//Actual server feature here...
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Keep in mind that any sanitized props then have to returned in the `sanitizedServerFeatureProps` property.
|
||||
|
||||
In the client feature, it works in a similar way:
|
||||
|
||||
```ts
|
||||
createClientFeature<UnSanitizedClientProps, SanitizedClientProps>(
|
||||
({ clientFunctions, featureProviderMap, props, resolvedFeatures, unSanitizedEditorConfig }) => {
|
||||
const sanitizedProps = doSomethingWithProps(props)
|
||||
return {
|
||||
sanitizedClientFeatureProps: sanitizedProps,
|
||||
//Actual client feature here...
|
||||
}
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### Bringing props from the server to the client
|
||||
|
||||
By default, the client feature will never receive any props from the server feature. In order to pass props from the server to the client, you can need to return those props in the server feature:
|
||||
|
||||
```ts
|
||||
type UnSanitizedClientProps = {
|
||||
test: string
|
||||
}
|
||||
|
||||
createServerFeature<UnSanitizedProps, SanitizedProps, UnSanitizedClientProps>({
|
||||
//...
|
||||
feature: {
|
||||
clientFeatureProps: {
|
||||
test: 'myValue'
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
The reason the client feature does not have the same props available as the server by default is because all client props need to be serializable. You can totally accept things like functions or Maps as props in your server feature, but you will not be able to send those to the client. In the end, those props are sent from the server to the client over the network, so they need to be serializable.
|
||||
|
||||
## More information
|
||||
|
||||
Take a look at the [features we've already built](https://github.com/payloadcms/payload/tree/beta/packages/richtext-lexical/src/features) - understanding how they work will help you understand how to create your own. There is no difference between the features included by default and the ones you create yourself - since those features are all isolated from the "core", you have access to the same APIs, whether the feature is part of payload or not!
|
||||
@@ -6,7 +6,6 @@ desc: Conversion between lexical, markdown and html
|
||||
keywords: lexical, rich text, editor, headless cms, convert, html, mdx, markdown, md, conversion, export
|
||||
---
|
||||
|
||||
|
||||
## Lexical => HTML
|
||||
|
||||
Lexical saves data in JSON, but can also generate its HTML representation via two main methods:
|
||||
@@ -21,7 +20,7 @@ The editor comes with built-in HTML serializers, simplifying the process of conv
|
||||
To add HTML generation directly within the collection, follow the example below:
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload/types'
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { HTMLConverterFeature, lexicalEditor, lexicalHTML } from '@payloadcms/richtext-lexical'
|
||||
|
||||
@@ -66,6 +65,8 @@ async function lexicalToHTML(
|
||||
return await convertLexicalToHTML({
|
||||
converters: consolidateHTMLConverters({ editorConfig }),
|
||||
data: editorData,
|
||||
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)
|
||||
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.
|
||||
})
|
||||
}
|
||||
```
|
||||
@@ -95,19 +96,33 @@ HTML Converters are typed as `HTMLConverter`, which contains the node type it sh
|
||||
import type { HTMLConverter } from '@payloadcms/richtext-lexical'
|
||||
|
||||
const UploadHTMLConverter: HTMLConverter<SerializedUploadNode> = {
|
||||
converter: async ({ node, payload }) => {
|
||||
const uploadDocument = await payload.findByID({
|
||||
id: node.value.id,
|
||||
collection: node.relationTo,
|
||||
})
|
||||
const url = (payload?.config?.serverURL || '') + uploadDocument?.url
|
||||
converter: async ({ node, req }) => {
|
||||
const uploadDocument: {
|
||||
value?: any
|
||||
} = {}
|
||||
if(req) {
|
||||
await populate({
|
||||
id,
|
||||
collectionSlug: node.relationTo,
|
||||
currentDepth: 0,
|
||||
data: uploadDocument,
|
||||
depth: 1,
|
||||
draft: false,
|
||||
key: 'value',
|
||||
overrideAccess: false,
|
||||
req,
|
||||
showHiddenFields: false,
|
||||
})
|
||||
}
|
||||
|
||||
if (!(uploadDocument?.mimeType as string)?.startsWith('image')) {
|
||||
const url = (req?.payload?.config?.serverURL || '') + uploadDocument?.value?.url
|
||||
|
||||
if (!(uploadDocument?.value?.mimeType as string)?.startsWith('image')) {
|
||||
// Only images can be serialized as HTML
|
||||
return ``
|
||||
}
|
||||
|
||||
return `<img src="${url}" alt="${uploadDocument?.filename}" width="${uploadDocument?.width}" height="${uploadDocument?.height}"/>`
|
||||
return `<img src="${url}" alt="${uploadDocument?.value?.filename}" width="${uploadDocument?.value?.width}" height="${uploadDocument?.value?.height}"/>`
|
||||
},
|
||||
nodeTypes: [UploadNode.getType()], // This is the type of the lexical node that this converter can handle. Instead of hardcoding 'upload' we can get the node type directly from the UploadNode, since it's static.
|
||||
}
|
||||
@@ -200,7 +215,7 @@ yourEditorConfig.features = [
|
||||
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/types'
|
||||
import type { CollectionConfig, RichTextField } from 'payload'
|
||||
import { createHeadlessEditor } from '@lexical/headless'
|
||||
import type { LexicalRichTextAdapter, SanitizedServerEditorConfig } from '@payloadcms/richtext-lexical'
|
||||
import {
|
||||
|
||||
@@ -17,7 +17,7 @@ One way to handle this is to just give your lexical editor the ability to read t
|
||||
Simply add the `SlateToLexicalFeature` to your editor:
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload/types'
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { SlateToLexicalFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
|
||||
@@ -49,7 +49,7 @@ The easy way to solve this: Just save the document! This overrides the slate dat
|
||||
The method described above does not solve the issue for all documents, though. If you want to convert all your documents to lexical, you can use a migration script. Here's a simple example:
|
||||
|
||||
```ts
|
||||
import type { Payload } from 'payload'
|
||||
import type { Payload, YourDocumentType } from 'payload'
|
||||
import type { YourDocumentType } from 'payload/generated-types'
|
||||
|
||||
import {
|
||||
@@ -146,7 +146,7 @@ When using a migration script, you can add your custom converters to the `conver
|
||||
When using the `SlateToLexicalFeature`, you can add your custom converters to the `converters` property of the `SlateToLexicalFeature` props:
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload/types'
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import {
|
||||
SlateToLexicalFeature,
|
||||
|
||||
@@ -32,7 +32,7 @@ npm install @payloadcms/richtext-lexical
|
||||
Once you have it installed, you can pass it to your top-level Payload config as follows:
|
||||
|
||||
```ts
|
||||
import { buildConfig } from 'payload/config'
|
||||
import { buildConfig } from 'payload'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
|
||||
export default buildConfig({
|
||||
@@ -47,7 +47,7 @@ export default buildConfig({
|
||||
You can also override Lexical settings on a field-by-field basis as follows:
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload/types'
|
||||
import type { CollectionConfig } from 'payload'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const Pages: CollectionConfig = {
|
||||
@@ -168,12 +168,4 @@ Notice how even the toolbars are features? That's how extensible our lexical edi
|
||||
|
||||
## Creating your own, custom Feature
|
||||
|
||||
Creating your own custom feature requires deep knowledge of the Lexical editor. We recommend you take a look at the [Lexical documentation](https://lexical.dev/docs/intro) first - especially the "concepts" section.
|
||||
|
||||
Next, take a look at the [features we've already built](https://github.com/payloadcms/payload/tree/main/packages/richtext-lexical/src/field/features) - understanding how they work will help you understand how to create your own. There is no difference between the features included by default and the ones you create yourself - since those features are all isolated from the "core", you have access to the same APIs, whether the feature is part of payload or not!
|
||||
|
||||
## Coming Soon
|
||||
|
||||
Lots more documentation will be coming soon, which will show in detail how to create your own custom features within Lexical.
|
||||
|
||||
For now, take a look at the TypeScript interfaces and let us know if you need a hand. Much more will be coming from the Payload team on this topic soon.
|
||||
You can find more information about creating your own feature in our [building custom feature docs](lexical/building-custom-features).
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { ListItemNode, ListNode } from '@lexical/list'
|
||||
|
||||
import type { FeatureProviderProviderServer } from '../../types.js'
|
||||
|
||||
// eslint-disable-next-line payload/no-imports-from-exports-dir
|
||||
import { OrderedListFeatureClient } from '../../../exports/client/index.js'
|
||||
import { createServerFeature } from '../../../utilities/createServerFeature.js'
|
||||
|
||||
@@ -20,8 +20,9 @@ export type ToolbarGroup =
|
||||
}
|
||||
|
||||
export type ToolbarGroupItem = {
|
||||
/** A React component which is rendered within your toolbar item's default button component. Usually, you want this to be an icon. */
|
||||
ChildComponent?: React.FC
|
||||
/** Use component to ignore the children and onClick properties. It does not use the default, pre-defined format Button component */
|
||||
/** A React component which is rendered in place of the toolbar item's default button component, thus completely replacing it. The `ChildComponent` and `onSelect` properties will be ignored. */
|
||||
Component?: React.FC<{
|
||||
active?: boolean
|
||||
anchorElem: HTMLElement
|
||||
@@ -29,6 +30,7 @@ export type ToolbarGroupItem = {
|
||||
enabled?: boolean
|
||||
item: ToolbarGroupItem
|
||||
}>
|
||||
/** This is optional and controls if the toolbar item is highlighted or not. */
|
||||
isActive?: ({
|
||||
editor,
|
||||
editorConfigContext,
|
||||
@@ -38,6 +40,7 @@ export type ToolbarGroupItem = {
|
||||
editorConfigContext: EditorConfigContextType
|
||||
selection: BaseSelection
|
||||
}) => boolean
|
||||
/** This is optional and controls if the toolbar item is clickable or not. If `false` is returned here, it will be grayed out and unclickable. */
|
||||
isEnabled?: ({
|
||||
editor,
|
||||
editorConfigContext,
|
||||
@@ -47,9 +50,11 @@ export type ToolbarGroupItem = {
|
||||
editorConfigContext: EditorConfigContextType
|
||||
selection: BaseSelection
|
||||
}) => boolean
|
||||
/** Each toolbar item needs to have a unique key. */
|
||||
key: string
|
||||
/** The label is displayed as text if the item is part of a dropdown group */
|
||||
/** The label will be displayed in your toolbar item, if it's within a dropdown group. In order to make use of i18n, this can be a function. */
|
||||
label?: (({ i18n }: { i18n: I18nClient<{}, string> }) => string) | string
|
||||
/** Each toolbar item needs to have a unique key. */
|
||||
onSelect?: ({ editor, isActive }: { editor: LexicalEditor; isActive: boolean }) => void
|
||||
order?: number
|
||||
}
|
||||
|
||||
@@ -92,11 +92,11 @@ export type FeatureProviderServer<
|
||||
ServerFeatureProps = UnSanitizedServerFeatureProps,
|
||||
ClientFeatureProps = undefined,
|
||||
> = {
|
||||
/** Keys of dependencies needed for this feature. These dependencies do not have to be loaded first */
|
||||
/** Keys of dependencies needed for this feature. These dependencies do not have to be loaded first, but they have to exist, otherwise an error will be thrown. */
|
||||
dependencies?: string[]
|
||||
/** Keys of priority dependencies needed for this feature. These dependencies have to be loaded first and are available in the `feature` property*/
|
||||
/** Keys of priority dependencies needed for this feature. These dependencies have to be loaded first AND have to exist, otherwise an error will be thrown. They will be available in the `feature` property. */
|
||||
dependenciesPriority?: string[]
|
||||
/** Keys of soft-dependencies needed for this feature. The FeatureProviders dependencies are optional, but are considered as last-priority in the loading process */
|
||||
/** Keys of soft-dependencies needed for this feature. These are optional. Payload will attempt to load them before this feature, but doesn't throw an error if that's not possible. */
|
||||
dependenciesSoft?: string[]
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import type { I18n, I18nClient } from '@payloadcms/translations'
|
||||
import type { I18nClient } from '@payloadcms/translations'
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import type React from 'react'
|
||||
|
||||
export type SlashMenuItem = {
|
||||
// Icon for display
|
||||
/** The icon which is rendered in your slash menu item. */
|
||||
Icon: React.FC
|
||||
|
||||
// Used for class names and, if label is not provided, for display.
|
||||
/** Each slash menu item needs to have a unique key. The key will be matched when typing, displayed if no `label` property is set, and used for classNames. */
|
||||
key: string
|
||||
// TBD
|
||||
keyboardShortcut?: string
|
||||
// For extra searching.
|
||||
/**
|
||||
* Keywords are used in order to match the item for different texts typed after the '/'.
|
||||
* E.g. you might want to show a horizontal rule item if you type both /hr, /separator, /horizontal etc.
|
||||
* Additionally to the keywords, the label and key will be used to match the correct slash menu item.
|
||||
*/
|
||||
keywords?: Array<string>
|
||||
/** The label will be displayed in your slash menu item. In order to make use of i18n, this can be a function. */
|
||||
label?: (({ i18n }: { i18n: I18nClient<{}, string> }) => string) | string
|
||||
// What happens when you select this item?
|
||||
/** A function which is called when the slash menu item is selected. */
|
||||
onSelect: ({ editor, queryString }: { editor: LexicalEditor; queryString: string }) => void
|
||||
}
|
||||
|
||||
export type SlashMenuGroup = {
|
||||
items: Array<SlashMenuItem>
|
||||
key: string
|
||||
// Used for class names and, if label is not provided, for display.
|
||||
key: string
|
||||
label?: (({ i18n }: { i18n: I18nClient<{}, string> }) => string) | string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user