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: ![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. --- 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: [ {