Originally this PR was going to introduce a `TextColorFeature`, but it
ended up becoming a more general-purpose `TextStateFeature`.
## Example of use:
```ts
import { defaultColors, TextStateFeature } from '@payloadcms/richtext-lexical'
TextStateFeature({
// prettier-ignore
state: {
color: {
...defaultColors,
// fancy gradients!
galaxy: { label: 'Galaxy', css: { background: 'linear-gradient(to right, #0000ff, #ff0000)', color: 'white' } },
sunset: { label: 'Sunset', css: { background: 'linear-gradient(to top, #ff5f6d, #6a3093)' } },
},
// You can have both colored and underlined text at the same time.
// If you don't want that, you should group them within the same key.
// (just like I did with defaultColors and my fancy gradients)
underline: {
'solid': { label: 'Solid', css: { 'text-decoration': 'underline', 'text-underline-offset': '4px' } },
// You'll probably want to use the CSS light-dark() utility.
'yellow-dashed': { label: 'Yellow Dashed', css: { 'text-decoration': 'underline dashed', 'text-decoration-color': 'light-dark(#EAB308,yellow)', 'text-underline-offset': '4px' } },
},
},
}),
```
Which will result in the following:

## Challenges & Considerations
Adding colors or styles in general to the Lexical editor is not as
simple as it seems.
1. **Extending TextNode isn't ideal**
- While possible, it's verbose, error-prone, and not composable. If
multiple features extend the same node, conflicts arise.
- That’s why we collaborated with the Lexical team to introduce [the new
State API](https://lexical.dev/docs/concepts/node-replacement)
([PR](https://github.com/facebook/lexical/pull/7117)).
2. **Issues with patchStyles**
- Some community plugins use `patchStyles`, but storing CSS in the
editor’s JSON has drawbacks:
- Style adaptability: Users may want different styles per scenario
(dark/light mode, mobile/web, etc.).
- Migration challenges: Hardcoded colors (e.g., #FF0000) make updates
difficult. Using tokens (e.g., "red") allows flexibility.
- Larger JSON footprint increases DB size.
3. **Managing overlapping styles**
- Some users may want both text and background colors on the same node,
while others may prefer mutual exclusivity.
- This approach allows either:
- Using a single "color" state (e.g., "bg-red" + "text-red").
- Defining separate "bg-color" and "text-color" states for independent
styling.
4. **Good light and dark modes by default**
- Many major editors (Google Docs, OneNote, Word) treat dark mode as an
afterthought, leading to poor UX.
- We provide a well-balanced default palette that looks great in both
themes, serving as a strong foundation for customization.
5. **Feature name. Why TextState?**
- Other names considered were `TextFormatFeature` and
`TextStylesFeature`. The term `format` in Lexical and Payload is already
used to refer to something else (italic, bold, etc.). The term `style`
could be misleading since it is never attached to the editorState.
- State seems appropriate because:
- Lexical's new state API is used under the hood.
- Perhaps in the future we'll want to make state features for other
nodes, such as `ElementStateFeature` or `RootStateFeature`.
Note: There's a bug in Lexical's `forEachSelectedTextNode`. When the
selection includes a textNode partially on the left, all state for that
node is removed instead of splitting it along the selection edge.
81 lines
2.5 KiB
TypeScript
81 lines
2.5 KiB
TypeScript
import type { LexicalEditor, StateConfig } from 'lexical'
|
|
|
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
|
import { $forEachSelectedTextNode } from '@lexical/selection'
|
|
import { $getNodeByKey, $getState, $setState, createState, TextNode } from 'lexical'
|
|
import { useEffect } from 'react'
|
|
|
|
import { type StateValues, type TextStateFeatureProps } from './feature.server.js'
|
|
|
|
const stateMap = new Map<
|
|
string,
|
|
{
|
|
stateConfig: StateConfig<string, string | undefined>
|
|
stateValues: StateValues
|
|
}
|
|
>()
|
|
|
|
export function registerTextStates(state: TextStateFeatureProps['state']) {
|
|
for (const stateKey in state) {
|
|
const stateValues = state[stateKey]!
|
|
const stateConfig = createState(stateKey, {
|
|
parse: (value) =>
|
|
typeof value === 'string' && Object.keys(stateValues).includes(value) ? value : undefined,
|
|
})
|
|
stateMap.set(stateKey, { stateConfig, stateValues })
|
|
}
|
|
}
|
|
|
|
export function setTextState(editor: LexicalEditor, stateKey: string, value: string | undefined) {
|
|
editor.update(() => {
|
|
$forEachSelectedTextNode((textNode) => {
|
|
const stateMapEntry = stateMap.get(stateKey)
|
|
if (!stateMapEntry) {
|
|
throw new Error(`State config for ${stateKey} not found`)
|
|
}
|
|
$setState(textNode, stateMapEntry.stateConfig, value)
|
|
})
|
|
})
|
|
}
|
|
|
|
export function StatePlugin() {
|
|
const [editor] = useLexicalComposerContext()
|
|
|
|
useEffect(() => {
|
|
return editor.registerMutationListener(TextNode, (mutatedNodes) => {
|
|
editor.getEditorState().read(() => {
|
|
for (const [nodeKey, mutation] of mutatedNodes) {
|
|
if (mutation === 'destroyed') {
|
|
continue
|
|
}
|
|
const node = $getNodeByKey(nodeKey)
|
|
const dom = editor.getElementByKey(nodeKey)
|
|
if (!node || !dom) {
|
|
continue
|
|
}
|
|
// stateKey could be color for example
|
|
stateMap.forEach((stateEntry, _stateKey) => {
|
|
// stateValue could be bg-red for example
|
|
const stateValue = $getState(node, stateEntry.stateConfig)
|
|
if (!stateValue) {
|
|
delete dom.dataset[_stateKey]
|
|
dom.style.cssText = ''
|
|
return
|
|
}
|
|
dom.dataset[_stateKey] = stateValue
|
|
const css = stateEntry.stateValues[stateValue]?.css
|
|
if (!css) {
|
|
return
|
|
}
|
|
Object.entries(css).forEach(([key, value]) => {
|
|
dom.style.setProperty(key, value)
|
|
})
|
|
})
|
|
}
|
|
})
|
|
})
|
|
}, [editor])
|
|
|
|
return null
|
|
}
|