Files
payload/packages/richtext-lexical/src/features/textState/textState.ts
Germán Jabloñski fc83823e5d feat(richtext-lexical): add TextStateFeature (allows applying styles such as color and background color to text) (#9667)
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:


![image](https://github.com/user-attachments/assets/ed29b30b-8efd-4265-a1b9-125c97ac5fce)


## 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.
2025-05-21 23:58:17 +00:00

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
}