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:

## 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.
This commit is contained in:
@@ -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 `<hr>` 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 `<hr>` 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!
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: () => <TextStateIcon css={meta.css} />,
|
||||
key: stateValue,
|
||||
label: meta.label,
|
||||
onSelect: ({ editor }) => {
|
||||
setTextState(editor, stateKey, stateValue)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const clearStyle: ToolbarDropdownGroup['items'] = [
|
||||
{
|
||||
ChildComponent: () => <TextStateIcon />,
|
||||
key: `clear-style`,
|
||||
label: 'Default style',
|
||||
onSelect: ({ editor }) => {
|
||||
for (const stateKey in props.state) {
|
||||
setTextState(editor, stateKey, undefined)
|
||||
}
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
]
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'dropdown',
|
||||
ChildComponent: () => <TextStateIcon css={{ color: 'var(--theme-elevation-600)' }} />,
|
||||
items: [...clearStyle, ...items],
|
||||
key: 'textState',
|
||||
order: 30,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export const TextStateFeatureClient = createClientFeature<TextStateFeatureProps>(({ props }) => {
|
||||
registerTextStates(props.state)
|
||||
return {
|
||||
plugins: [
|
||||
{
|
||||
Component: StatePlugin,
|
||||
position: 'normal',
|
||||
},
|
||||
],
|
||||
toolbarFixed: {
|
||||
groups: toolbarGroups(props),
|
||||
},
|
||||
toolbarInline: {
|
||||
groups: toolbarGroups(props),
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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<PropertiesHyphenFallback[K], string>
|
||||
// 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',
|
||||
})
|
||||
@@ -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<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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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 (
|
||||
<span
|
||||
style={{
|
||||
...convertedCss,
|
||||
alignItems: 'center',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
fontSize: '16px',
|
||||
height: '20px',
|
||||
justifyContent: 'center',
|
||||
width: '20px',
|
||||
}}
|
||||
>
|
||||
A
|
||||
</span>
|
||||
)
|
||||
}
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user