Skip to content

[lexical-extension] Feature: Implement DecoratorTextExtension applying format to DecoratorTextNode#8114

Open
levensta wants to merge 17 commits intofacebook:mainfrom
levensta:decorator-text-node
Open

[lexical-extension] Feature: Implement DecoratorTextExtension applying format to DecoratorTextNode#8114
levensta wants to merge 17 commits intofacebook:mainfrom
levensta:decorator-text-node

Conversation

@levensta
Copy link
Contributor

@levensta levensta commented Feb 4, 2026

Description

Added a new extension DecoratorTextExtension which provides a new DecoratorTextNode class and the ability to apply text formatting to it. DecoratorTextNode — a new node extended from DecoratorNode. The implementation of the node is simple and is actually the same DecoratorNode, but with formatting methods exactly the same as those of TextNode.

This feature can be useful for decorator nodes that need to be rendered in the middle of text and behave like text when the user wants to convert a part in a some format. Support for specific formats and how they affect the visual component of the decorator is left up to the user. As an example, I made how this might work for DateTimeNode in lexical-playground

Test plan

Before

Screen.Recording.2026-02-04.at.05.42.50.mov

After

Screen.Recording.2026-02-04.at.05.40.45.mov

@vercel
Copy link

vercel bot commented Feb 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment Feb 14, 2026 1:10pm
lexical-playground Ready Ready Preview, Comment Feb 14, 2026 1:10pm

Request Review

@meta-cla meta-cla bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Feb 4, 2026
Comment on lines 1 to 12
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
*/

/**
* LexicalDecoratorTextNode
*/
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by the update-flowconfig command, but I didn't understand whether I needed to add the types manually. The build-types command returned an error. Perhaps it would be worth adding a brief explanation to CONTRIBUTING.md of how to correctly add new exportable things to packages 🤔

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pnpm run update-flowconfig only creates the files, it doesn't try and write the flow types for you.

pnpm run build-types is unrelated to flow, it only runs typescript. it does look like there's a problem with it worth fixing or filing an issue for, it tries to type check the build products in a way that doesn't make sense, so pnpm build-types will fail if you have run pnpm run build without pnpm run clean or equivalent.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it does look like there's a problem with it worth fixing or filing an issue for

I attempted to run the command on node v24.11.1. After switching to v22.22.0, pnpm run build-types executed successfully

Copy link
Collaborator

@etrepum etrepum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't taken a very close look but this does seem like a lot of code for this feature, without any tests, and it appears to be failing existing tests. I suspect that it could be simpler.

@levensta
Copy link
Contributor Author

levensta commented Feb 6, 2026

I haven't taken a very close look but this does seem like a lot of code for this feature, without any tests, and it appears to be failing existing tests. I suspect that it could be simpler.

I simplified the implementation a little and fixed fundamental errors that caused the tests to fail

Comment on lines 1224 to +1228
await toggleBold(page);
await assertHTML(
page,
// After formatting the text, the selection will be reset from the decorator node,
// so it will retain its previous format when toggleBold is triggered again
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this is related to the TextPointType update in selection.formatText https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalSelection.ts#L1319-L1325

Screen.Recording.2026-02-07.at.02.03.48.mov

@ivailop7
Copy link
Collaborator

ivailop7 commented Feb 8, 2026

It would be nice if the font size and font color attributes worked as well.

@levensta
Copy link
Contributor Author

levensta commented Feb 8, 2026

It would be nice if the font size and font color attributes worked as well.

Playground sets styles through the $patchStyleText function, which works with TextNode or ElementNode. A primitive solution would be to make this function work with the new DecoratorTextNode type, but I think it's wrong to pull the dependency from the lexical-react package into the lower-level lexical-selection

I think the right way is to add style management to the playground via a separate plugin and the PATCH_TEXT_STYLE_COMMAND command, as done in one of the examples. Then override the command handler in the TextWithFormattedContents component, which I implemented in this PR, and apply styles to DecoratorTextNode.

Anyway, I would suggest doing it in a separate PR, since supporting styles for non-text nodes is a non-trivial feature, especially if you need to come up with a mechanism to limit the set of possible styles for a specific node/class.

Copy link
Collaborator

@etrepum etrepum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely getting close! I agree that style can be handled separately from this PR.

}>;
}>;

export function TextWithFormattedContents({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't look like the best separation of concerns, it looks like it would register a command for each one of these nodes in the document rather than consolidating it to a single plugin. I know there is some old code that does this already but it's not a good way to do things. HorizontalRuleExtension is an example of something recent that consolidates the command listeners.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the tip. I rewrote it from a component to an extension and removed the className setting because with the current implementation it is redundant and much easier to set all the necessary classes inside the component itself

SerializedLexicalNode
>;

export class DecoratorTextNode extends DecoratorNode<JSX.Element> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@levensta levensta changed the title [lexical-react] Feature: Implement DecoratorTextNode and TextWithFormattedContents component [lexical-extension] Feature: Implement DecoratorTextExtension applying format to DecoratorTextNode Feb 12, 2026
Copy link
Collaborator

@etrepum etrepum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, just some small cleanups to do!

Comment on lines 206 to 227
if ($isNodeSelection(selection)) {
selection.getNodes().forEach((node) => {
if ($isDecoratorTextNode(node)) {
node.toggleFormat(formatType);
}
});
} else if ($isRangeSelection(selection)) {
const nodes = selection.getNodes();

for (const node of nodes) {
if ($isDecoratorTextNode(node)) {
node.toggleFormat(formatType);
} else {
const decoratorText = $findMatchingParent(
node,
$isDecoratorTextNode,
);
if (decoratorText !== null) {
decoratorText.toggleFormat(formatType);
}
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$isDecoratorTextNode will never return true for an ElementNode so there's no need to check parents

Suggested change
if ($isNodeSelection(selection)) {
selection.getNodes().forEach((node) => {
if ($isDecoratorTextNode(node)) {
node.toggleFormat(formatType);
}
});
} else if ($isRangeSelection(selection)) {
const nodes = selection.getNodes();
for (const node of nodes) {
if ($isDecoratorTextNode(node)) {
node.toggleFormat(formatType);
} else {
const decoratorText = $findMatchingParent(
node,
$isDecoratorTextNode,
);
if (decoratorText !== null) {
decoratorText.toggleFormat(formatType);
}
}
}
if ($isNodeSelection(selection) || $isRangeSelection(selection)) {
for (const node of selection.getNodes()) {
if ($isDecoratorTextNode(node)) {
node.toggleFormat(formatType);
}
}
}

Comment on lines 205 to 206
.filter(([flag]) => format & Number(flag))
.map(([, className]) => className),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These can array returning methods be fused into a flatMap, although it would probably make more sense to have formatClassMap just be an array of pairs in the first place rather than converting it to one on demand every time.

Suggested change
.filter(([flag]) => format & Number(flag))
.map(([, className]) => className),
.flatMap(([flag, className]) => format & Number(flag) ? [className] : [])

or

// This would be top-level in the module
const FORMAT_CLASSES = [
  [IS_BOLD, 'bold'],
  [IS_HIGHLIGHT, 'highlight'],
  [IS_ITALIC, 'italic'],
  [IS_STRIKETHROUGH, 'strikethrough'],
  [IS_UNDERLINE, 'underline'],
] as const;

// …
const classNames = ['dateTimePill'];
for (const [flag, className] of FORMAT_CLASSES) {
  if (format & flag) {
    classNames.push(className);
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants