From fc83823e5d47052a782c99f60a8dd6500ef503ec Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Germ=C3=A1n=20Jablo=C3=B1ski?=
<43938777+GermanJablo@users.noreply.github.com>
Date: Wed, 21 May 2025 20:58:17 -0300
Subject: [PATCH] feat(richtext-lexical): add TextStateFeature (allows applying
styles such as color and background color to text) (#9667)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
docs/rich-text/overview.mdx | 53 +--
packages/richtext-lexical/package.json | 1 +
.../src/exports/client/index.ts | 1 +
.../src/features/textState/defaultColors.ts | 318 ++++++++++++++++++
.../src/features/textState/feature.client.tsx | 69 ++++
.../src/features/textState/feature.server.ts | 77 +++++
.../src/features/textState/textState.ts | 80 +++++
packages/richtext-lexical/src/index.ts | 7 +-
.../src/lexical/ui/icons/TextState/index.tsx | 30 ++
pnpm-lock.yaml | 3 +
.../_LexicalFullyFeatured/e2e.spec.ts | 14 +
.../_LexicalFullyFeatured/index.ts | 8 +
12 files changed, 633 insertions(+), 28 deletions(-)
create mode 100644 packages/richtext-lexical/src/features/textState/defaultColors.ts
create mode 100644 packages/richtext-lexical/src/features/textState/feature.client.tsx
create mode 100644 packages/richtext-lexical/src/features/textState/feature.server.ts
create mode 100644 packages/richtext-lexical/src/features/textState/textState.ts
create mode 100644 packages/richtext-lexical/src/lexical/ui/icons/TextState/index.tsx
diff --git a/docs/rich-text/overview.mdx b/docs/rich-text/overview.mdx
index e11d5900cc..48d9463a4b 100644
--- a/docs/rich-text/overview.mdx
+++ b/docs/rich-text/overview.mdx
@@ -142,32 +142,33 @@ import { CallToAction } from '../blocks/CallToAction'
Here's an overview of all the included features:
-| Feature Name | Included by default | Description |
-| ------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **`BoldFeature`** | Yes | Handles the bold text format |
-| **`ItalicFeature`** | Yes | Handles the italic text format |
-| **`UnderlineFeature`** | Yes | Handles the underline text format |
-| **`StrikethroughFeature`** | Yes | Handles the strikethrough text format |
-| **`SubscriptFeature`** | Yes | Handles the subscript text format |
-| **`SuperscriptFeature`** | Yes | Handles the superscript text format |
-| **`InlineCodeFeature`** | Yes | Handles the inline-code text format |
-| **`ParagraphFeature`** | Yes | Handles paragraphs. Since they are already a key feature of lexical itself, this Feature mainly handles the Slash and Add-Block menu entries for paragraphs |
-| **`HeadingFeature`** | Yes | Adds Heading Nodes (by default, H1 - H6, but that can be customized) |
-| **`AlignFeature`** | Yes | Allows you to align text left, centered and right |
-| **`IndentFeature`** | Yes | Allows you to indent text with the tab key |
-| **`UnorderedListFeature`** | Yes | Adds unordered lists (ul) |
-| **`OrderedListFeature`** | Yes | Adds ordered lists (ol) |
-| **`ChecklistFeature`** | Yes | Adds checklists |
-| **`LinkFeature`** | Yes | Allows you to create internal and external links |
-| **`RelationshipFeature`** | Yes | Allows you to create block-level (not inline) relationships to other documents |
-| **`BlockquoteFeature`** | Yes | Allows you to create block-level quotes |
-| **`UploadFeature`** | Yes | Allows you to create block-level upload nodes - this supports all kinds of uploads, not just images |
-| **`HorizontalRuleFeature`** | Yes | Horizontal rules / separators. Basically displays an `
` element |
-| **`InlineToolbarFeature`** | Yes | The inline toolbar is the floating toolbar which appears when you select text. This toolbar only contains actions relevant for selected text |
-| **`FixedToolbarFeature`** | No | This classic toolbar is pinned to the top and always visible. Both inline and fixed toolbars can be enabled at the same time. |
-| **`BlocksFeature`** | No | Allows you to use Payload's [Blocks Field](../fields/blocks) directly inside your editor. In the feature props, you can specify the allowed blocks - just like in the Blocks field. |
-| **`TreeViewFeature`** | No | Adds a debug box under the editor, which allows you to see the current editor state live, the dom, as well as time travel. Very useful for debugging |
-| **`EXPERIMENTAL_TableFeature`** | No | Adds support for tables. This feature may be removed or receive breaking changes in the future - even within a stable lexical release, without needing a major release. |
+| Feature Name | Included by default | Description |
+| ----------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **`BoldFeature`** | Yes | Handles the bold text format |
+| **`ItalicFeature`** | Yes | Handles the italic text format |
+| **`UnderlineFeature`** | Yes | Handles the underline text format |
+| **`StrikethroughFeature`** | Yes | Handles the strikethrough text format |
+| **`SubscriptFeature`** | Yes | Handles the subscript text format |
+| **`SuperscriptFeature`** | Yes | Handles the superscript text format |
+| **`InlineCodeFeature`** | Yes | Handles the inline-code text format |
+| **`ParagraphFeature`** | Yes | Handles paragraphs. Since they are already a key feature of lexical itself, this Feature mainly handles the Slash and Add-Block menu entries for paragraphs |
+| **`HeadingFeature`** | Yes | Adds Heading Nodes (by default, H1 - H6, but that can be customized) |
+| **`AlignFeature`** | Yes | Allows you to align text left, centered and right |
+| **`IndentFeature`** | Yes | Allows you to indent text with the tab key |
+| **`UnorderedListFeature`** | Yes | Adds unordered lists (ul) |
+| **`OrderedListFeature`** | Yes | Adds ordered lists (ol) |
+| **`ChecklistFeature`** | Yes | Adds checklists |
+| **`LinkFeature`** | Yes | Allows you to create internal and external links |
+| **`RelationshipFeature`** | Yes | Allows you to create block-level (not inline) relationships to other documents |
+| **`BlockquoteFeature`** | Yes | Allows you to create block-level quotes |
+| **`UploadFeature`** | Yes | Allows you to create block-level upload nodes - this supports all kinds of uploads, not just images |
+| **`HorizontalRuleFeature`** | Yes | Horizontal rules / separators. Basically displays an `
` element |
+| **`InlineToolbarFeature`** | Yes | The inline toolbar is the floating toolbar which appears when you select text. This toolbar only contains actions relevant for selected text |
+| **`FixedToolbarFeature`** | No | This classic toolbar is pinned to the top and always visible. Both inline and fixed toolbars can be enabled at the same time. |
+| **`BlocksFeature`** | No | Allows you to use Payload's [Blocks Field](../fields/blocks) directly inside your editor. In the feature props, you can specify the allowed blocks - just like in the Blocks field. |
+| **`TreeViewFeature`** | No | Adds a debug box under the editor, which allows you to see the current editor state live, the dom, as well as time travel. Very useful for debugging |
+| **`EXPERIMENTAL_TableFeature`** | No | Adds support for tables. This feature may be removed or receive breaking changes in the future - even within a stable lexical release, without needing a major release. |
+| **`EXPERIMENTAL_TextStateFeature`** | No | Allows you to store key-value attributes within TextNodes and assign them inline styles. |
Notice how even the toolbars are features? That's how extensible our lexical editor is - you could theoretically create your own toolbar if you wanted to!
diff --git a/packages/richtext-lexical/package.json b/packages/richtext-lexical/package.json
index 9403021f4e..3edfd24f58 100644
--- a/packages/richtext-lexical/package.json
+++ b/packages/richtext-lexical/package.json
@@ -370,6 +370,7 @@
"@types/uuid": "10.0.0",
"acorn": "8.12.1",
"bson-objectid": "2.0.4",
+ "csstype": "3.1.3",
"dequal": "2.0.3",
"escape-html": "1.0.3",
"jsox": "1.2.121",
diff --git a/packages/richtext-lexical/src/exports/client/index.ts b/packages/richtext-lexical/src/exports/client/index.ts
index 57f539f5e4..1fecbde589 100644
--- a/packages/richtext-lexical/src/exports/client/index.ts
+++ b/packages/richtext-lexical/src/exports/client/index.ts
@@ -20,6 +20,7 @@ export { StrikethroughFeatureClient } from '../../features/format/strikethrough/
export { SubscriptFeatureClient } from '../../features/format/subscript/feature.client.js'
export { SuperscriptFeatureClient } from '../../features/format/superscript/feature.client.js'
export { UnderlineFeatureClient } from '../../features/format/underline/feature.client.js'
+export { TextStateFeatureClient } from '../../features/textState/feature.client.js'
export { HeadingFeatureClient } from '../../features/heading/client/index.js'
export { HorizontalRuleFeatureClient } from '../../features/horizontalRule/client/index.js'
export { IndentFeatureClient } from '../../features/indent/client/index.js'
diff --git a/packages/richtext-lexical/src/features/textState/defaultColors.ts b/packages/richtext-lexical/src/features/textState/defaultColors.ts
new file mode 100644
index 0000000000..0e3a7599bc
--- /dev/null
+++ b/packages/richtext-lexical/src/features/textState/defaultColors.ts
@@ -0,0 +1,318 @@
+import type { StateValues } from './feature.server.js'
+
+const tailwindColors = {
+ amber: {
+ '50': 'oklch(0.987 0.022 95.277)',
+ '100': 'oklch(0.962 0.059 95.617)',
+ '200': 'oklch(0.924 0.12 95.746)',
+ '300': 'oklch(0.879 0.169 91.605)',
+ '400': 'oklch(0.828 0.189 84.429)',
+ '500': 'oklch(0.769 0.188 70.08)',
+ '600': 'oklch(0.666 0.179 58.318)',
+ '700': 'oklch(0.555 0.163 48.998)',
+ '800': 'oklch(0.473 0.137 46.201)',
+ '900': 'oklch(0.414 0.112 45.904)',
+ '950': 'oklch(0.279 0.077 45.635)',
+ },
+ black: '#000',
+ blue: {
+ '50': 'oklch(0.97 0.014 254.604)',
+ '100': 'oklch(0.932 0.032 255.585)',
+ '200': 'oklch(0.882 0.059 254.128)',
+ '300': 'oklch(0.809 0.105 251.813)',
+ '400': 'oklch(0.707 0.165 254.624)',
+ '500': 'oklch(0.623 0.214 259.815)',
+ '600': 'oklch(0.546 0.245 262.881)',
+ '700': 'oklch(0.488 0.243 264.376)',
+ '800': 'oklch(0.424 0.199 265.638)',
+ '900': 'oklch(0.379 0.146 265.522)',
+ '950': 'oklch(0.282 0.091 267.935)',
+ },
+ current: 'currentColor',
+ cyan: {
+ '50': 'oklch(0.984 0.019 200.873)',
+ '100': 'oklch(0.956 0.045 203.388)',
+ '200': 'oklch(0.917 0.08 205.041)',
+ '300': 'oklch(0.865 0.127 207.078)',
+ '400': 'oklch(0.789 0.154 211.53)',
+ '500': 'oklch(0.715 0.143 215.221)',
+ '600': 'oklch(0.609 0.126 221.723)',
+ '700': 'oklch(0.52 0.105 223.128)',
+ '800': 'oklch(0.45 0.085 224.283)',
+ '900': 'oklch(0.398 0.07 227.392)',
+ '950': 'oklch(0.302 0.056 229.695)',
+ },
+ emerald: {
+ '50': 'oklch(0.979 0.021 166.113)',
+ '100': 'oklch(0.95 0.052 163.051)',
+ '200': 'oklch(0.905 0.093 164.15)',
+ '300': 'oklch(0.845 0.143 164.978)',
+ '400': 'oklch(0.765 0.177 163.223)',
+ '500': 'oklch(0.696 0.17 162.48)',
+ '600': 'oklch(0.596 0.145 163.225)',
+ '700': 'oklch(0.508 0.118 165.612)',
+ '800': 'oklch(0.432 0.095 166.913)',
+ '900': 'oklch(0.378 0.077 168.94)',
+ '950': 'oklch(0.262 0.051 172.552)',
+ },
+ fuchsia: {
+ '50': 'oklch(0.977 0.017 320.058)',
+ '100': 'oklch(0.952 0.037 318.852)',
+ '200': 'oklch(0.903 0.076 319.62)',
+ '300': 'oklch(0.833 0.145 321.434)',
+ '400': 'oklch(0.74 0.238 322.16)',
+ '500': 'oklch(0.667 0.295 322.15)',
+ '600': 'oklch(0.591 0.293 322.896)',
+ '700': 'oklch(0.518 0.253 323.949)',
+ '800': 'oklch(0.452 0.211 324.591)',
+ '900': 'oklch(0.401 0.17 325.612)',
+ '950': 'oklch(0.293 0.136 325.661)',
+ },
+ gray: {
+ '50': 'oklch(0.985 0.002 247.839)',
+ '100': 'oklch(0.967 0.003 264.542)',
+ '200': 'oklch(0.928 0.006 264.531)',
+ '300': 'oklch(0.872 0.01 258.338)',
+ '400': 'oklch(0.707 0.022 261.325)',
+ '500': 'oklch(0.551 0.027 264.364)',
+ '600': 'oklch(0.446 0.03 256.802)',
+ '700': 'oklch(0.373 0.034 259.733)',
+ '800': 'oklch(0.278 0.033 256.848)',
+ '900': 'oklch(0.21 0.034 264.665)',
+ '950': 'oklch(0.13 0.028 261.692)',
+ },
+ green: {
+ '50': 'oklch(0.982 0.018 155.826)',
+ '100': 'oklch(0.962 0.044 156.743)',
+ '200': 'oklch(0.925 0.084 155.995)',
+ '300': 'oklch(0.871 0.15 154.449)',
+ '400': 'oklch(0.792 0.209 151.711)',
+ '500': 'oklch(0.723 0.219 149.579)',
+ '600': 'oklch(0.627 0.194 149.214)',
+ '700': 'oklch(0.527 0.154 150.069)',
+ '800': 'oklch(0.448 0.119 151.328)',
+ '900': 'oklch(0.393 0.095 152.535)',
+ '950': 'oklch(0.266 0.065 152.934)',
+ },
+ indigo: {
+ '50': 'oklch(0.962 0.018 272.314)',
+ '100': 'oklch(0.93 0.034 272.788)',
+ '200': 'oklch(0.87 0.065 274.039)',
+ '300': 'oklch(0.785 0.115 274.713)',
+ '400': 'oklch(0.673 0.182 276.935)',
+ '500': 'oklch(0.585 0.233 277.117)',
+ '600': 'oklch(0.511 0.262 276.966)',
+ '700': 'oklch(0.457 0.24 277.023)',
+ '800': 'oklch(0.398 0.195 277.366)',
+ '900': 'oklch(0.359 0.144 278.697)',
+ '950': 'oklch(0.257 0.09 281.288)',
+ },
+ inherit: 'inherit',
+ lime: {
+ '50': 'oklch(0.986 0.031 120.757)',
+ '100': 'oklch(0.967 0.067 122.328)',
+ '200': 'oklch(0.938 0.127 124.321)',
+ '300': 'oklch(0.897 0.196 126.665)',
+ '400': 'oklch(0.841 0.238 128.85)',
+ '500': 'oklch(0.768 0.233 130.85)',
+ '600': 'oklch(0.648 0.2 131.684)',
+ '700': 'oklch(0.532 0.157 131.589)',
+ '800': 'oklch(0.453 0.124 130.933)',
+ '900': 'oklch(0.405 0.101 131.063)',
+ '950': 'oklch(0.274 0.072 132.109)',
+ },
+ neutral: {
+ '50': 'oklch(0.985 0 0)',
+ '100': 'oklch(0.97 0 0)',
+ '200': 'oklch(0.922 0 0)',
+ '300': 'oklch(0.87 0 0)',
+ '400': 'oklch(0.708 0 0)',
+ '500': 'oklch(0.556 0 0)',
+ '600': 'oklch(0.439 0 0)',
+ '700': 'oklch(0.371 0 0)',
+ '800': 'oklch(0.269 0 0)',
+ '900': 'oklch(0.205 0 0)',
+ '950': 'oklch(0.145 0 0)',
+ },
+ orange: {
+ '50': 'oklch(0.98 0.016 73.684)',
+ '100': 'oklch(0.954 0.038 75.164)',
+ '200': 'oklch(0.901 0.076 70.697)',
+ '300': 'oklch(0.837 0.128 66.29)',
+ '400': 'oklch(0.75 0.183 55.934)',
+ '500': 'oklch(0.705 0.213 47.604)',
+ '600': 'oklch(0.646 0.222 41.116)',
+ '700': 'oklch(0.553 0.195 38.402)',
+ '800': 'oklch(0.47 0.157 37.304)',
+ '900': 'oklch(0.408 0.123 38.172)',
+ '950': 'oklch(0.266 0.079 36.259)',
+ },
+ pink: {
+ '50': 'oklch(0.971 0.014 343.198)',
+ '100': 'oklch(0.948 0.028 342.258)',
+ '200': 'oklch(0.899 0.061 343.231)',
+ '300': 'oklch(0.823 0.12 346.018)',
+ '400': 'oklch(0.718 0.202 349.761)',
+ '500': 'oklch(0.656 0.241 354.308)',
+ '600': 'oklch(0.592 0.249 0.584)',
+ '700': 'oklch(0.525 0.223 3.958)',
+ '800': 'oklch(0.459 0.187 3.815)',
+ '900': 'oklch(0.408 0.153 2.432)',
+ '950': 'oklch(0.284 0.109 3.907)',
+ },
+ purple: {
+ '50': 'oklch(0.977 0.014 308.299)',
+ '100': 'oklch(0.946 0.033 307.174)',
+ '200': 'oklch(0.902 0.063 306.703)',
+ '300': 'oklch(0.827 0.119 306.383)',
+ '400': 'oklch(0.714 0.203 305.504)',
+ '500': 'oklch(0.627 0.265 303.9)',
+ '600': 'oklch(0.558 0.288 302.321)',
+ '700': 'oklch(0.496 0.265 301.924)',
+ '800': 'oklch(0.438 0.218 303.724)',
+ '900': 'oklch(0.381 0.176 304.987)',
+ '950': 'oklch(0.291 0.149 302.717)',
+ },
+ red: {
+ '50': 'oklch(0.971 0.013 17.38)',
+ '100': 'oklch(0.936 0.032 17.717)',
+ '200': 'oklch(0.885 0.062 18.334)',
+ '300': 'oklch(0.808 0.114 19.571)',
+ '400': 'oklch(0.704 0.191 22.216)',
+ '500': 'oklch(0.637 0.237 25.331)',
+ '600': 'oklch(0.577 0.245 27.325)',
+ '700': 'oklch(0.505 0.213 27.518)',
+ '800': 'oklch(0.444 0.177 26.899)',
+ '900': 'oklch(0.396 0.141 25.723)',
+ '950': 'oklch(0.258 0.092 26.042)',
+ },
+ rose: {
+ '50': 'oklch(0.969 0.015 12.422)',
+ '100': 'oklch(0.941 0.03 12.58)',
+ '200': 'oklch(0.892 0.058 10.001)',
+ '300': 'oklch(0.81 0.117 11.638)',
+ '400': 'oklch(0.712 0.194 13.428)',
+ '500': 'oklch(0.645 0.246 16.439)',
+ '600': 'oklch(0.586 0.253 17.585)',
+ '700': 'oklch(0.514 0.222 16.935)',
+ '800': 'oklch(0.455 0.188 13.697)',
+ '900': 'oklch(0.41 0.159 10.272)',
+ '950': 'oklch(0.271 0.105 12.094)',
+ },
+ sky: {
+ '50': 'oklch(0.977 0.013 236.62)',
+ '100': 'oklch(0.951 0.026 236.824)',
+ '200': 'oklch(0.901 0.058 230.902)',
+ '300': 'oklch(0.828 0.111 230.318)',
+ '400': 'oklch(0.746 0.16 232.661)',
+ '500': 'oklch(0.685 0.169 237.323)',
+ '600': 'oklch(0.588 0.158 241.966)',
+ '700': 'oklch(0.5 0.134 242.749)',
+ '800': 'oklch(0.443 0.11 240.79)',
+ '900': 'oklch(0.391 0.09 240.876)',
+ '950': 'oklch(0.293 0.066 243.157)',
+ },
+ slate: {
+ '50': 'oklch(0.984 0.003 247.858)',
+ '100': 'oklch(0.968 0.007 247.896)',
+ '200': 'oklch(0.929 0.013 255.508)',
+ '300': 'oklch(0.869 0.022 252.894)',
+ '400': 'oklch(0.704 0.04 256.788)',
+ '500': 'oklch(0.554 0.046 257.417)',
+ '600': 'oklch(0.446 0.043 257.281)',
+ '700': 'oklch(0.372 0.044 257.287)',
+ '800': 'oklch(0.279 0.041 260.031)',
+ '900': 'oklch(0.208 0.042 265.755)',
+ '950': 'oklch(0.129 0.042 264.695)',
+ },
+ stone: {
+ '50': 'oklch(0.985 0.001 106.423)',
+ '100': 'oklch(0.97 0.001 106.424)',
+ '200': 'oklch(0.923 0.003 48.717)',
+ '300': 'oklch(0.869 0.005 56.366)',
+ '400': 'oklch(0.709 0.01 56.259)',
+ '500': 'oklch(0.553 0.013 58.071)',
+ '600': 'oklch(0.444 0.011 73.639)',
+ '700': 'oklch(0.374 0.01 67.558)',
+ '800': 'oklch(0.268 0.007 34.298)',
+ '900': 'oklch(0.216 0.006 56.043)',
+ '950': 'oklch(0.147 0.004 49.25)',
+ },
+ teal: {
+ '50': 'oklch(0.984 0.014 180.72)',
+ '100': 'oklch(0.953 0.051 180.801)',
+ '200': 'oklch(0.91 0.096 180.426)',
+ '300': 'oklch(0.855 0.138 181.071)',
+ '400': 'oklch(0.777 0.152 181.912)',
+ '500': 'oklch(0.704 0.14 182.503)',
+ '600': 'oklch(0.6 0.118 184.704)',
+ '700': 'oklch(0.511 0.096 186.391)',
+ '800': 'oklch(0.437 0.078 188.216)',
+ '900': 'oklch(0.386 0.063 188.416)',
+ '950': 'oklch(0.277 0.046 192.524)',
+ },
+ transparent: 'transparent',
+ violet: {
+ '50': 'oklch(0.969 0.016 293.756)',
+ '100': 'oklch(0.943 0.029 294.588)',
+ '200': 'oklch(0.894 0.057 293.283)',
+ '300': 'oklch(0.811 0.111 293.571)',
+ '400': 'oklch(0.702 0.183 293.541)',
+ '500': 'oklch(0.606 0.25 292.717)',
+ '600': 'oklch(0.541 0.281 293.009)',
+ '700': 'oklch(0.491 0.27 292.581)',
+ '800': 'oklch(0.432 0.232 292.759)',
+ '900': 'oklch(0.38 0.189 293.745)',
+ '950': 'oklch(0.283 0.141 291.089)',
+ },
+ white: '#fff',
+ yellow: {
+ '50': 'oklch(0.987 0.026 102.212)',
+ '100': 'oklch(0.973 0.071 103.193)',
+ '200': 'oklch(0.945 0.129 101.54)',
+ '300': 'oklch(0.905 0.182 98.111)',
+ '400': 'oklch(0.852 0.199 91.936)',
+ '500': 'oklch(0.795 0.184 86.047)',
+ '600': 'oklch(0.681 0.162 75.834)',
+ '700': 'oklch(0.554 0.135 66.442)',
+ '800': 'oklch(0.476 0.114 61.907)',
+ '900': 'oklch(0.421 0.095 57.708)',
+ '950': 'oklch(0.286 0.066 53.813)',
+ },
+ zinc: {
+ '50': 'oklch(0.985 0 0)',
+ '100': 'oklch(0.967 0.001 286.375)',
+ '200': 'oklch(0.92 0.004 286.32)',
+ '300': 'oklch(0.871 0.006 286.286)',
+ '400': 'oklch(0.705 0.015 286.067)',
+ '500': 'oklch(0.552 0.016 285.938)',
+ '600': 'oklch(0.442 0.017 285.786)',
+ '700': 'oklch(0.37 0.013 285.805)',
+ '800': 'oklch(0.274 0.006 286.033)',
+ '900': 'oklch(0.21 0.006 285.885)',
+ '950': 'oklch(0.141 0.005 285.823)',
+ },
+}
+
+// prettier-ignore
+/* eslint-disable perfectionist/sort-objects */
+export const defaultColors = {
+ text: {
+ 'text-red': { css: { 'color': `light-dark(${tailwindColors.red[600]}, ${tailwindColors.red[400]})`, }, label: 'Red' },
+ 'text-orange': { css: { 'color': `light-dark(${tailwindColors.orange[600]}, ${tailwindColors.orange[400]})`, }, label: 'Orange' },
+ 'text-yellow': { css: { 'color': `light-dark(${tailwindColors.yellow[700]}, ${tailwindColors.yellow[300]})`, }, label: 'Yellow' },
+ 'text-green': { css: { 'color': `light-dark(${tailwindColors.green[700]}, ${tailwindColors.green[400]})`, }, label: 'Green' },
+ 'text-blue': { css: { 'color': `light-dark(${tailwindColors.blue[600]}, ${tailwindColors.blue[400]})`, }, label: 'Blue' },
+ 'text-purple': { css: { 'color': `light-dark(${tailwindColors.purple[600]}, ${tailwindColors.purple[400]})`, }, label: 'Purple' },
+ 'text-pink': { css: { 'color': `light-dark(${tailwindColors.pink[600]}, ${tailwindColors.pink[400]})`, }, label: 'Pink' },
+ } satisfies StateValues,
+ background: {
+ 'bg-red': { css: { 'background-color': `light-dark(${tailwindColors.red[400]}, ${tailwindColors.red[600]})`, }, label: 'Red' },
+ 'bg-orange': { css: { 'background-color': `light-dark(${tailwindColors.orange[400]}, ${tailwindColors.orange[600]})`, }, label: 'Orange' },
+ 'bg-yellow': { css: { 'background-color': `light-dark(${tailwindColors.yellow[300]}, ${tailwindColors.yellow[700]})`, }, label: 'Yellow' },
+ 'bg-green': { css: { 'background-color': `light-dark(${tailwindColors.green[400]}, ${tailwindColors.green[700]})`, }, label: 'Green' },
+ 'bg-blue': { css: { 'background-color': `light-dark(${tailwindColors.blue[400]}, ${tailwindColors.blue[600]})`, }, label: 'Blue' },
+ 'bg-purple': { css: { 'background-color': `light-dark(${tailwindColors.purple[400]}, ${tailwindColors.purple[600]})`, }, label: 'Purple' },
+ 'bg-pink': { css: { 'background-color': `light-dark(${tailwindColors.pink[400]}, ${tailwindColors.pink[600]})`, }, label: 'Pink' },
+ } satisfies StateValues
+ }
diff --git a/packages/richtext-lexical/src/features/textState/feature.client.tsx b/packages/richtext-lexical/src/features/textState/feature.client.tsx
new file mode 100644
index 0000000000..0a7b5f22ab
--- /dev/null
+++ b/packages/richtext-lexical/src/features/textState/feature.client.tsx
@@ -0,0 +1,69 @@
+'use client'
+
+import type { ToolbarDropdownGroup, ToolbarGroup } from '../toolbars/types.js'
+import type { TextStateFeatureProps } from './feature.server.js'
+
+import { TextStateIcon } from '../../lexical/ui/icons/TextState/index.js'
+import { createClientFeature } from '../../utilities/createClientFeature.js'
+import { registerTextStates, setTextState, StatePlugin } from './textState.js'
+
+const toolbarGroups = (props: TextStateFeatureProps): ToolbarGroup[] => {
+ const items: ToolbarDropdownGroup['items'] = []
+
+ for (const stateKey in props.state) {
+ const key = props.state[stateKey]!
+ for (const stateValue in key) {
+ const meta = key[stateValue]!
+ items.push({
+ ChildComponent: () => ,
+ key: stateValue,
+ label: meta.label,
+ onSelect: ({ editor }) => {
+ setTextState(editor, stateKey, stateValue)
+ },
+ })
+ }
+ }
+
+ const clearStyle: ToolbarDropdownGroup['items'] = [
+ {
+ ChildComponent: () => ,
+ key: `clear-style`,
+ label: 'Default style',
+ onSelect: ({ editor }) => {
+ for (const stateKey in props.state) {
+ setTextState(editor, stateKey, undefined)
+ }
+ },
+ order: 1,
+ },
+ ]
+
+ return [
+ {
+ type: 'dropdown',
+ ChildComponent: () => ,
+ items: [...clearStyle, ...items],
+ key: 'textState',
+ order: 30,
+ },
+ ]
+}
+
+export const TextStateFeatureClient = createClientFeature(({ props }) => {
+ registerTextStates(props.state)
+ return {
+ plugins: [
+ {
+ Component: StatePlugin,
+ position: 'normal',
+ },
+ ],
+ toolbarFixed: {
+ groups: toolbarGroups(props),
+ },
+ toolbarInline: {
+ groups: toolbarGroups(props),
+ },
+ }
+})
diff --git a/packages/richtext-lexical/src/features/textState/feature.server.ts b/packages/richtext-lexical/src/features/textState/feature.server.ts
new file mode 100644
index 0000000000..61768bd86e
--- /dev/null
+++ b/packages/richtext-lexical/src/features/textState/feature.server.ts
@@ -0,0 +1,77 @@
+import type { PropertiesHyphenFallback } from 'csstype'
+import type { Prettify } from 'ts-essentials'
+
+import { createServerFeature } from '../../utilities/createServerFeature.js'
+
+// extracted from https://github.com/facebook/lexical/pull/7294
+export type StyleObject = Prettify<{
+ [K in keyof PropertiesHyphenFallback]?:
+ | Extract
+ // This is simplified to not deal with arrays or numbers.
+ // This is an example after all!
+ | undefined
+}>
+
+export type StateValues = { [stateValue: string]: { css: StyleObject; label: string } }
+
+export type TextStateFeatureProps = {
+ /**
+ * The keys of the top-level object (stateKeys) represent the attributes that the textNode can have (e.g., color).
+ * The values of the top-level object (stateValues) represent the values that the attribute can have (e.g., red, blue, etc.).
+ * Within the stateValue, you can define inline styles and labels.
+ *
+ * @note Because this is a common use case, we provide a defaultColors object with colors that
+ * look good in both dark and light mode, which you can use or adapt to your liking.
+ *
+ *
+ *
+ * @example
+ * import { defaultColors } from '@payloadcms/richtext-lexical'
+ *
+ * state: {
+ * color: {
+ * ...defaultColors.background,
+ * ...defaultColors.text,
+ * // 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' } },
+ * },
+ * }
+ *
+ */
+ state: { [stateKey: string]: StateValues }
+}
+
+/**
+ * Allows you to store key-value attributes within TextNodes and define inline styles for each combination.
+ * Inline styles are not part of the editorState, reducing the JSON size and allowing you to easily migrate or adapt styles later.
+ *
+ * This feature can be used, among other things, to add colors to text.
+ *
+ * For more information and examples, see the JSdocs for the "state" property that this feature receives as a parameter.
+ *
+ * @experimental There may be breaking changes to this API
+ */
+export const TextStateFeature = createServerFeature<
+ TextStateFeatureProps,
+ TextStateFeatureProps,
+ TextStateFeatureProps
+>({
+ feature: ({ props }) => {
+ return {
+ ClientFeature: '@payloadcms/richtext-lexical/client#TextStateFeatureClient',
+ clientFeatureProps: {
+ state: props.state,
+ },
+ }
+ },
+ key: 'textState',
+})
diff --git a/packages/richtext-lexical/src/features/textState/textState.ts b/packages/richtext-lexical/src/features/textState/textState.ts
new file mode 100644
index 0000000000..4bcccbfb0c
--- /dev/null
+++ b/packages/richtext-lexical/src/features/textState/textState.ts
@@ -0,0 +1,80 @@
+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
+ 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
+}
diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts
index 31a937a439..d10a51c166 100644
--- a/packages/richtext-lexical/src/index.ts
+++ b/packages/richtext-lexical/src/index.ts
@@ -925,21 +925,21 @@ export { HeadingFeature, type HeadingFeatureProps } from './features/heading/ser
export { HorizontalRuleFeature } from './features/horizontalRule/server/index.js'
export { IndentFeature } from './features/indent/server/index.js'
-
export { AutoLinkNode } from './features/link/nodes/AutoLinkNode.js'
export { LinkNode } from './features/link/nodes/LinkNode.js'
+
export type { LinkFields } from './features/link/nodes/types.js'
export { LinkFeature, type LinkFeatureServerProps } from './features/link/server/index.js'
export { ChecklistFeature } from './features/lists/checklist/server/index.js'
export { OrderedListFeature } from './features/lists/orderedList/server/index.js'
export { UnorderedListFeature } from './features/lists/unorderedList/server/index.js'
-
export type {
SlateNode,
SlateNodeConverter,
} from './features/migrations/slateToLexical/converter/types.js'
export { ParagraphFeature } from './features/paragraph/server/index.js'
+
export {
RelationshipFeature,
type RelationshipFeatureProps,
@@ -949,6 +949,9 @@ export {
type RelationshipData,
RelationshipServerNode,
} from './features/relationship/server/nodes/RelationshipNode.js'
+export { defaultColors } from './features/textState/defaultColors.js'
+export { TextStateFeature } from './features/textState/feature.server.js'
+
export { FixedToolbarFeature } from './features/toolbars/fixed/server/index.js'
export { InlineToolbarFeature } from './features/toolbars/inline/server/index.js'
diff --git a/packages/richtext-lexical/src/lexical/ui/icons/TextState/index.tsx b/packages/richtext-lexical/src/lexical/ui/icons/TextState/index.tsx
new file mode 100644
index 0000000000..a641c07c90
--- /dev/null
+++ b/packages/richtext-lexical/src/lexical/ui/icons/TextState/index.tsx
@@ -0,0 +1,30 @@
+import type { StyleObject } from '../../../../features/textState/feature.server.js'
+
+function kebabToCamelCase(str: string): string {
+ return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
+}
+
+export const TextStateIcon: React.FC<{
+ css?: StyleObject
+}> = ({ css }) => {
+ const convertedCss = css
+ ? Object.fromEntries(Object.entries(css).map(([key, value]) => [kebabToCamelCase(key), value]))
+ : {}
+
+ return (
+
+ A
+
+ )
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0007f9a263..ba58b004fc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1295,6 +1295,9 @@ importers:
bson-objectid:
specifier: 2.0.4
version: 2.0.4
+ csstype:
+ specifier: 3.1.3
+ version: 3.1.3
dequal:
specifier: 2.0.3
version: 2.0.3
diff --git a/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts b/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts
index ac986a998d..41cd7aefb8 100644
--- a/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts
+++ b/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts
@@ -65,4 +65,18 @@ describe('Lexical Fully Featured', () => {
const paragraph = lexical.editor.locator('> p')
await expect(paragraph).toHaveText('')
})
+ test('text state feature', async ({ page }) => {
+ await page.keyboard.type('Hello')
+ await page.keyboard.press('ControlOrMeta+A')
+ await page.locator('.toolbar-popup__dropdown-textState').first().click()
+ await page.getByRole('button', { name: 'Red' }).first().click()
+ const colored = page.locator('span').filter({ hasText: 'Hello' })
+ await expect(colored).toHaveCSS('background-color', 'oklch(0.704 0.191 22.216)')
+ await expect(colored).toHaveAttribute('data-color', 'bg-red')
+ await page.locator('.toolbar-popup__dropdown-textState').first().click()
+ await page.getByRole('button', { name: 'Default style' }).click()
+ await expect(colored).toBeVisible()
+ await expect(colored).not.toHaveCSS('background-color', 'oklch(0.704 0.191 22.216)')
+ await expect(colored).not.toHaveAttribute('data-color', 'bg-red')
+ })
})
diff --git a/test/lexical/collections/_LexicalFullyFeatured/index.ts b/test/lexical/collections/_LexicalFullyFeatured/index.ts
index f8d8156e8f..08b31c27cd 100644
--- a/test/lexical/collections/_LexicalFullyFeatured/index.ts
+++ b/test/lexical/collections/_LexicalFullyFeatured/index.ts
@@ -2,9 +2,11 @@ import type { CollectionConfig } from 'payload'
import {
BlocksFeature,
+ defaultColors,
EXPERIMENTAL_TableFeature,
FixedToolbarFeature,
lexicalEditor,
+ TextStateFeature,
TreeViewFeature,
} from '@payloadcms/richtext-lexical'
@@ -21,11 +23,17 @@ export const LexicalFullyFeatured: CollectionConfig = {
name: 'richText',
type: 'richText',
editor: lexicalEditor({
+ // Try to keep feature props simple and minimal in this collection
features: ({ defaultFeatures }) => [
...defaultFeatures,
TreeViewFeature(),
FixedToolbarFeature(),
EXPERIMENTAL_TableFeature(),
+ TextStateFeature({
+ state: {
+ color: { ...defaultColors.background, ...defaultColors.text },
+ },
+ }),
BlocksFeature({
blocks: [
{