Rich Text Editor
Rich text editors allow the user to format text. In ActiveCampaign, these are often used when formatting emails, or in builders (such as the Pages builder, Campaigns builder, etc.)
Loading...
Overview
Resources
Install
yarn add @activecampaign/camp-components-rich-text-editor
Usage
The Camp RichTextEditor is built with flexibility in mind in order to meet a variety of needs within the platform. For a React-based end-to-end editing experience, we take advantage of a library called Lexical that offers a powerful and highly customizable rich text editor experience.
The Camp RichTextEditor aligns with the Lexical API conventions mentioned in their documentation. We strongly encourage that users read the docs, and understand some of these concepts as they work with the RichTextEditor component, as it will make using it / debugging it much easier.
You can opt into exactly what functionality you need using that editor, and we also export the editor-agnostic building blocks in case you need to use something other than Lexical.
Below we will walk through different ways the editor can be used starting with the most common.
Lexical Overview
Many of the features of Lexical / the RichTextEditor are designed to emulate a ‘native’ html input / textarea experience, but it is important to note that the rich text editor is not a native input / textarea element under the hood.
Lexical manages all events and updates internally via a state object called editor
.
This is a key concept to understand when working with Lexical, as it is the source of truth for the editor’s content and formatting.
The Lexical Playground example illustrates this well, showing the editor
state that updates in real time.
Lexical Core Concepts
LexicalComposer
In React terms, the LexicalComposer
is a React context provider that wraps the entire editor. It is required for all Lexical editors.
It accepts an initialConfig
object, many of which can be overridden / extended using props on the LexicalComposer
.
LexicalRichTextPlugin
Exposes a set of common functionalities that enables rich text editing, including bold, italic, underline, strike-through, alignment, text formatting, and copy and paste.
ContentEditable
Serves as the main text input area where users can type and edit content.
Plugins
Lexical is built around the concept of plugins, which are simply components that can be added to the editor to extend its functionality. Camp has already built many plugins for you. Refer to the plugins section below for more details on individual plugins that Camp supports.
Nodes
Nodes are a core concept of Lexical, and in short, are what represent editor content / the various things that can be rendered within the editor. For example, Camp exports a few ‘custom nodes’ such as an ImageNode (renders an image tag in the editor), a ClickableLinkNode (renders an a tag in the editor), etc.
Lexical Plugins
The RichTextEditor
package exports a variety of plugin components that can be easily used out of the box, allowing consumers to opt into individual RichTextEditor features easily.
These plugins not only render the UI for interfacing with the plugin (for example, the actual ‘icon button’ for the Bold plugin), but also manage the behavior of the editor when the plugin is interacted with (ie, transforming the selected text to bold by applying styles to the html within the editor..).
AlignText
This plugin allows the user to align content in the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalAlignText />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
BlockTypes
This plugin allows the user to change the ‘block type’ of the selected content in the editor (ex: heading1
, heading2
, paragraph
, etc ).
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalBlockTypes />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
Bold
This plugin allows the user to bold content in the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalBold />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
ClearFormatting
This plugin allows the user to clear any existing formatting from selected content in the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalClearFormatting />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
EmojiPicker
This plugin allows the user to insert emojis into the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
type RichTextEditorLexicalEmojisProps = {
/**
* The placement of the emoji picker popover.
*/
popoverPlacement?: Placement;
/**
* Whether the icon should be flush with the text. This is primarily used to emulate the plugin being 'within' a text area / input.
*/
flushIcon?: boolean;
};
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalEmojiPicker />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
FontFamily
This plugin allows the user to change the font family of selected content in the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalFontFamily />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
FontSize
This plugin allows the user to change the font size of selected content in the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalFontSize />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
Image
This plugin allows the user to insert images into the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
<RichTextEditor.LexicalComposer
customNodes={[RichTextEditor.ImageNode]} // required for the image to render in the editor
>
<RichTextEditor.LexicalImage />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>;
Indent
This plugin allows the user to indent content in the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalIndent />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
Italics
This plugin allows the user to italicize content in the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalItalics />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
Link
This plugin allows users to insert links into the editor. A user can click on a link to edit the text or url in a popover, or apply a link to a selected text node.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalLink />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
Lists
This plugin allows the user to insert lists into the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalLists />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
OnBlur
This plugin allows the user to specify an onBlur
event handler for the editor.
open console with ⌘ + ⌥ + J
in order to view console.log
output of onBlur
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
import React from 'react';
export type OnBlurPluginProps = {
/**
* Specify the onBlur event handler.
*
* @param event FocusEvent
* @returns
*/
onBlur: (event: React.FocusEvent<HTMLDivElement>) => void;
};
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalOnBlur
onBlur={(e: React.FocusEvent<HTMLDivElement>) => console.log(e, 'on blur fired')}
/>
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
OnSave
This plugin allows the user to specify an onSave
event handler (callback function that is called when the editor is updated) for the editor to track the ‘deserialized’ content of the editor.
In the onSave
callback, the argument is the ‘html result’ output of the generateHtmlFromNodes
function from lexical/html
.
open console with ⌘ + ⌥ + J
in order to view console.log
output of onSave
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
type OnSaveProps = {
/**
* Callback function that is called when the editor is updated.
* @param msg The html result of `generateHtmlFromNodes` from `lexical/html`.
*/
onSave: (msg: string) => void;
};
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalOnSave onSave={(msg) => console.log('msg', msg)} />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
Outdent
This plugin allows the user to outdent content in the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalOutdent />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
Personalization
This plugin allows the user to insert personalization tags into the editor that are represented by ‘violet’ colored camp
Chips.
The LexicalPersonalization
component is a plugin that renders a button in the toolbar that opens a nested menu of personalization tags.
When the plugin is being used, this ‘nested menu’ can also be prompted via ‘typeahead’ (typing %
) directly within the editor.
Upon making a selection, the selected personalization tag will be inserted into the editor as a PersonalizationNode
(a custom node the team has configured that renders the purple chip).
A few things to note:
- The
data
prop is a list ofPersonalizationTagGroup[]
objects that are passed to theLexicalComposer
component. - The
PersonalizationTagGroup
object is a response from theapi/3/personalizationTagGroups
endpoint. - The
PersonalizationNode
is required to be registered on theLexicalComposer
instance. Failing to do this will cause errors / will not render a chip in the editor upon making a selection.
The nested menu can be prompted via ‘typeahead’ (typing %
) or by clicking the IconButton (when button
is true
).
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
import { PersonalizationTagGroup } from '@activecampaign/platform-core-queries';
const data: PersonalizationTagGroup[] = [
{
id: '1',
name: 'contacts',
key: 'contactPersTags',
label: 'Contact',
fields: [
...
{
id: 'fullName',
value: 'Full Name',
persTag: 'FULLNAME',
},
...
],
},
{
id: '2',
name: 'accounts',
key: 'accountPersTags',
label: 'Account',
fields: [
{
id: '%ACCT_NAME%',
persTag: 'ACCT_NAME',
value: 'Name',
},
],
},
];
...
type LexicalPersonalizationProps = {
/**
* Optionally add a IconButton to the editor toolbar that prompts the personalization menu when clicked.
*/
button?: boolean;
/**
* Optionally flush the icon with the button. This is primarily used to emulate the plugin being 'within' a text area / input.
*/
flushIcon?: boolean;
};
return (
<RichTextEditor.LexicalComposer
data={data} // <-- pass a list of `PersonalizationTagGroup[]` objects here
customNodes={[RichTextEditor.PersonalizationNode]} // <-- enables the purple, personalization tag chip to render
>
<RichTextEditor.LexicalPersonalization />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
Personalization Placeholder
This functionality builds off of the above ‘Personalization’ implementation, the PersonalizationPlaceholderNode (an interactive, ‘dotted’ bordered Chip), allows the user to select a Personalization ‘tag’ from a menu, which will then replace it with a PersonalizationNode with your selection. A placeholder node can also optionally be removed by clicking the dismiss button.
Personalization Placeholders are preloaded into an RTE instance using one of our ‘serialization’ plugins we export from the RTE package (either the SerializeHtmlContentPlugin or the SerializePlainTextContentPlugin).
A string that contains any instances of the text PERSONALIZATION_TAG (can only have white space around it, and leading / trailing characters can only be punctuation) that’s passed to either serialization plugin will be replaced with the PersonalizationPlaceholderNode.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
import { PersonalizationTagGroup } from '@activecampaign/platform-core-queries';
const data: PersonalizationTagGroup[] = [
...
{
id: '1',
name: 'contacts',
key: 'contactPersTags',
label: 'Contact',
fields: [
{
id: 'lastName',
value: 'Last Name',
persTag: 'LASTNAME',
},
...
],
defaultCollapsed: true,
},
...
];
<RichTextEditor.LexicalComposer
data={data} // <-- pass a list of `PersonalizationTagGroup[]` objects here
customNodes={[
PersonalizationNode, // <-- enables the purple, personalization tag chip to render
PersonalizationPlaceholderNode // <-- enables the outlined, personalization 'placeholder' chip to render
]}>
<RichTextEditor.SerializeHtmlContentPlugin
htmlContent={"PERSONALIZATION_TAG %LASTNAME% PERSONALIZATION_TAG"} // <-- valid instances of `PERSONALIZATION_TAG` will be replaced with the PersonalizationPlaceholderNode
/>
...
..
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
Placeholder Validation Logic
There may be instances where the user needs to ‘validate’ that there are no placeholders left in a instance of the RTE. There may be instances where you’d like to ‘trigger’ the validation / invalid styling on some action, such as submitting a form.
To do so, we expose a boolean prop called invalidPlaceholderStyles
, which simply enables the `invalid’ styling for all placeholder chip instances within the editor.
We also expose a React hook called usePlaceholderValidation
, which will monitor a given editor instance (a ref to the editor) and will return a boolean value depending on if there are placeholders left.
Using it might look something like this:
...
const editorRef = React.useRef<LexicalEditor | null>(null); // <-- obtain 'ref' to the editor
const { hasPlaceholder } = usePlaceholderValidation(editorRef); // <-- pass 'ref' to hook, extract `hasPlaceholder`
const [invalid, setInvalid] = React.useState(false); // <-- invalid 'state', this will get set based on `hasPlaceholder` upon clicking the button below
return (
<>
<Button
onClick={() => {
if (hasPlaceholder) {
setInvalid(true); // <-- flag validation if placeholders exist in the editor
return;
}
console.log('submitted without placeholders'); // <-- if no placeholders exist anymore, execute some other code
}}
>
Submit
</Button>
<LexicalComposer
data={data}
editorRef={editorRef} // <-- obtain a ref to the RTE
invalidPlaceholderStyling={invalid} // <-- invalid placeholder styling controlled by state
customNodes={[PersonalizationNode, PersonalizationPlaceholderNode]}
>
<SerializeHtmlContentPlugin htmlContent={emailMessage} />
<Textarea>
<LexicalRichTextPlugin />
</Textarea>
</LexicalComposer>
</>
);
...
Saved Responses
This plugin allows the user to insert various fields from a Saved Response into an editor instance.
The onSelect
function is designed with flexibility in mind, supporting the use case of inserting the savedResponse.body
into one editor, and the savedResponse.subject
into a separate editor.
a few things to note:
- The
savedResponse
object is a response from theapi/3/savedResponses?include=savedResponseCategorySavedResponse.savedResponseCategory
endpoint.- The nested menu will display the saved response titles as selectable options, grouped under the ‘category’ that it belongs to.
- The
appendSavedResponse
function will insert the content at the end of the existing editor, it should not overwrite existing content.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
// full response object from `api/3/savedResponses?include=savedResponseCategorySavedResponse.savedResponseCategory`
export const responseObject: RichTextEditor.SavedResponseResponseObject = {
savedResponseCategories: [
{
title: 'Follow Ups',
cdate: '2023-09-25T11:57:43-05:00',
mdate: null,
links: {
savedResponseCategorySavedResponses:
'https://ds-team.staging.listfly.com/api/3/savedResponseCategories/1/savedResponseCategorySavedResponses',
},
id: '1',
},
...
],
savedResponseCategorySavedResponses: [
{
savedresponsecategoryid: '1',
savedresponseid: '3',
savedResponseCategory: '1',
links: {
savedResponseCategory:
'https://ds-team.staging.listfly.com/api/3/savedResponseCategorySavedResponses/3/savedResponseCategory',
savedResponse:
'https://ds-team.staging.listfly.com/api/3/savedResponseCategorySavedResponses/3/savedResponse',
},
id: '3',
savedResponse: '3',
},
...
],
savedResponses: [
{
title: 'Hello Customer',
subject: "Hello <span data-lexical-personalization='FIRSTNAME'>%FIRSTNAME%</span>!",
body: "Hi <span data-lexical-personalization='FIRSTNAME'>%FIRSTNAME%</span>,<br><br> Thank you for reaching out! Our team of highly trained dogs is currently sniffing out the perfect answer to your question. 🐶🔍<br><br> While they chase their tails and fetch the details, feel free to grab a treat and relax. We'll bark back with a response faster than a greyhound on a racetrack!<br><br> Stay golden, - Arun<br>",
ldate: null,
last_sent_user_id: '0',
cdate: '2025-03-26T10:18:22-05:00',
mdate: '2025-03-28T08:44:01-05:00',
savedResponseCategorySavedResponse: '1',
links: {
user: 'https://ds-team.staging.listfly.com/api/3/savedResponses/1/user',
savedResponseCategorySavedResponse:
'https://ds-team.staging.listfly.com/api/3/savedResponses/1/savedResponseCategorySavedResponse',
},
id: '1',
user: null,
},
],
meta: {
total: '3',
},
};
...
type LexicalSavedResponsesProps = {
/**
* The saved responses response object returned from `api/3//savedResponses?include=savedResponseCategorySavedResponse.savedResponseCategory`
*/
savedResponses: SavedResponseResponseObject;
/**
* The function to call when a saved response is selected.
* The function args give the user access to the `saveResponse` object, the `editor` instance, and an `appendSavedResponse` function.
*
* NOTE: This API is designed to enable insertion of the saved response into several editor instances (ex: `savedResponse.body` might get inserted into one editor, and `savedResponse.subject` into a different one).
* @param {OnSelectArguments}
* @returns void
*/
onSelect: ({ savedResponse, editor, appendSavedResponse }: OnSelectArguments) => void;
};
...
return (
<RichTextEditor.LexicalComposer
data={[personalizationTagGroupsData]} // refer to personalization section, this is a list of `PersonalizationTagGroup` objects
customNodes={[PersonalizationNode, PersonalizationPlaceholderNode]}
>
<RichTextEditor.LexicalSavedResponses
onSelect={async ({ savedResponse, editor, appendSavedResponse }) => {
/**
* the `appendSavedResponse` will simply insert some content at the end of the existing editor, it should not overwrite existing content.
*/
await appendSavedResponse({
editor, // this particular editor instance comes from the `onSelect` args, but this can also be a 'ref' to an editor (allows for inserting 'subject' into a different editor)
content: savedResponse.body, // the `saveResponse` html content
data: personalizationTagGroupsData, // personalization tag group data, required for serializing personalization tags that may be inside saved response
});
}}
savedResponses={responseObject} // saved responses response object
/>
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
RichText
The RichText
plugin is a special plugin that is used to render the content of the editor. It is required to be used in conjunction with the Textarea
or Input
component.
The available props for the LexicalRichTextPlugin
can be referenced in the RichTextEditor Storybook docs .
This plugin is simply returning an instance of LexicalRichTextPlugin
: https://lexical.dev/docs/react/plugins#LexicalRichTextPlugin
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
export type RichTextPluginLexicalProps = {
/**
* Optionally provide a custom contentEditable element to use in place of the default
*/
contentEditable?: JSX.Element;
/**
* Optionally provide a custom placeholder to use in place of the default
*/
placeholder?: string;
/**
* Specify if this RichTextPlugin is an input or textarea element
* @default 'textarea'
*/
editorType?: 'input' | 'textarea';
};
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
Usage with Textarea
Here’s an example of how to use the LexicalRichTextPlugin
as a Textarea
(along with the available props):
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
type RichTextEditorTextareaProps = {
/**
* The id of the textarea.
*/
id?: string;
/**
* The label for the textarea.
*/
label?: string;
/**
* Optionally specify the text area to show required styling
*/
required?: boolean;
/**
* Optionally specify the text area to show invalid styling
*/
invalid?: boolean;
/**
* Optionally specify the text area to be disabled
*/
disabled?: boolean;
/**
* Optionally specify whether to use textarea styles
*/
textareaStyles?: boolean;
/**
* Optionally specify the helper text for the textarea
*/
helperText?: string;
/**
* Optionally specify the children of the textarea
*/
children?: React.ReactNode;
/**
* Optionally override the styles of the textarea
*/
style?: React.CSSProperties;
/**
* Optionally specify the max width of the textarea
*/
maxWidth?: string;
/**
* Specify the character limit for the textarea
*/
characterLimit?: number;
/**
* Render callback for the character counter, allowing for displaying the character count, limit, and the calculated sms count
*/
renderCharacterCounter?: ({ count, limit, smsCount }) => React.ReactNode;
/**
* Specify if the character counter should allow for exceeding the character limit
*/
surpassCharLimit?: boolean;
};
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.Textarea label="My Textarea" required helperText="This is some helper text">
<RichTextEditor.LexicalRichTextPlugin
editorType="textarea" // <-- specify the editor type (set to 'textarea' by default, so not necessary to explicitly set this if using textarea)
/>
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
characterLimit
The characterLimit
prop is a numerical value that specifies the maximum number of characters that can be entered into the textarea. If the limit is met, the textarea will not accept any more input. (If you’d like to allow the user to exceed the limit, see the surpassCharLimit
prop below)
In an effort to support a flexible experience, the RichTextEditor Textarea
supports displaying the ‘character counter’ text via a render callback called renderCharacterCounter
. This callback returns a string that can be used to display the character count, limit, and the calculated sms count.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
const [uploadedImage, setUploadedImage] = useState<string | null>(null);
const [limit, setLimit] = useState(160);
...
return (
<RichTextEditor.LexicalComposer data={personalizationTagGroupList} customNodes={[PersonalizationNode]}>
<RichTextEditor.Textarea
characterLimit={limit}
renderCharacterCounter={({ count, limit }) =>
`${count}/${limit} characters`
}
...
>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
...
..
</RichTextEditor.LexicalComposer>
);
surpassCharLimit
The surpassCharLimit
prop is a boolean value that specifies whether the textarea should allow the user to exceed the character limit. This is useful for cases where you want to allow the user to enter more text than the specified limit (sms broadcast is a good example of this).
When this prop is set to true
, the textarea will allow the user to enter more text than the specified limit.
When the limit has been exceeded, the character counter can display the number of characters entered (count
), the limit
itself, and a numerical value (called smsCount
) representing how many times the limit has been exceeded. These values are available in the renderCharacterCounter
callback arguments.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
const [uploadedImage, setUploadedImage] = useState<string | null>(null);
const [limit, setLimit] = useState(160);
...
return (
<RichTextEditor.LexicalComposer data={personalizationTagGroupList} customNodes={[PersonalizationNode]}>
<RichTextEditor.Textarea
characterLimit={limit}
renderCharacterCounter={({ count, limit, smsCount }) =>
`${count}/${limit} characters | ${smsCount} ${uploadedImage ? 'MMS' : 'SMS'}`
}
...
surpassCharLimit={true}
>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
{/* Upload an image */}
...
..
</RichTextEditor.LexicalComposer>
);
Usage with Input
Here’s an example of how to use the LexicalRichTextPlugin
as an Input
(along with the available props):
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
type RichTextEditorInputProps = {
/**
* The id of the input.
*/
id: string;
/**
* Optionally specify the input to show invalid styling
*/
invalid?: boolean;
/**
* Optionally specify the input to be disabled
*/
disabled?: boolean;
/**
* Optionally specify the helper text to show below the input
*/
helperText?: string;
/**
* Optionally specify the children to render inside the input
*/
children?: React.ReactNode;
/**
* Optionally override the styles of the input
*/
style?: React.CSSProperties;
/**
* Optionally specify the max width of the input
*/
maxWidth?: string;
/**
* Optionally override the styles of the input
*/
styles?: React.CSSProperties;
};
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.Input id="my-input" helperText="This is some helper text">
<RichTextEditor.LexicalRichTextPlugin editorType="input" />
</RichTextEditor.Input>
</RichTextEditor.LexicalComposer>
);
Serialization (Encoding / Decoding Editor Content)
Data can be imported (serialized) and exported (deserialized) into a Lexical editor using either HTML or plain text. Our platform tends to prefer storing HTML so all our examples below show that.
The SerializeHtmlContentPlugin
allows the user to ‘preload’ the editor with stringified html content, and pattern-based serialization logic will automatically convert certain strings into Camp Chips (PersonalizationNode
’s, ClickableLinkNode
’s, or PersonalizationPlaceholderNode
’s) on load.
More specifically:
- if editor content that contains
%LASTNAME%
is passed to theSerializeHtmlContentPlugin
, (and that personalization tag exists in thedata
prop being passed to theLexicalComposer
) it will be converted into aPersonalizationNode
(a purple chip) when the editor is loaded. - if an
a
tag is loaded into the editor, it will be replaced with aClickableLinkNode
(clicking the link will prompt a popover menu, allowing the user to edit or remove the link) - if the text
PERSONALIZATION_TAG
is detected in the editor, it will be replaced with aPersonalizationPlaceholderNode
(a dotted bordered chip) that can be clicked to prompt a menu of personalization tags.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
import Button from '@activecampaign/camp-components-button';
import React from 'react';
const editorRef = React.useRef<RichTextEditor.LexicalEditor | null>(null);
const { hasPlaceholder } = RichTextEditor.usePlaceholderValidation(editorRef);
const [invalid, setInvalid] = React.useState(false);
const emailMessage = 'PERSONALIZATION_TAG %LASTNAME% %CO:app-e3f9b015-8ab3-4f3e-8f1e-cae3fade902b-ticket:submitter_id% PERSONALIZATION_TAG';
const data: PersonalizationTagGroup[] = [
{
id: '1',
name: 'contacts',
key: 'contactPersTags',
label: 'Contact',
fields: [
...
{
id: 'fullName',
value: 'Full Name',
persTag: 'FULLNAME',
},
],
},
{
id: '2',
name: 'accounts',
key: 'accountPersTags',
label: 'Account',
fields: [
{
id: '%ACCT_NAME%',
persTag: 'ACCT_NAME',
value: 'Name',
},
],
},
];
type SerializeHTMLContentProps = {
/**
* The HTML content to be serialized.
*/
htmlContent: string;
};
return (
...
<Button
onClick={() => {
if (hasPlaceholder) {
console.log('did not submit correctly, placeholders exist in editor still');
setInvalid(true);
return;
}
console.log('submitted without placeholders');
}}
>
Submit
</Button>
...
<LexicalComposer
data={data}
editorRef={editorRef}
invalidPlaceholderStyling={invalid}
customNodes={[RichTextEditor.PersonalizationNode, RichTextEditor.PersonalizationPlaceholderNode]}
>
<SerializeHtmlContentPlugin htmlContent={emailMessage} />
<Textarea>
<LexicalRichTextPlugin />
</Textarea>
</LexicalComposer>
);
Serializing Plain Text
We wrote a custom plugin for the Automations team to be able to import plain text while creating Personalization tags. This is now available in Camp to be used by anyone.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
export const serializePlainTextPersonalization: StoryFn = () => {
return (
<RichTextEditor.LexicalComposer
data={personalizationTagGroupsList}
customNodes={[RichTextEditor.PersonalizationNode]}
>
<RichTextEditor.LexicalPersonalization />
<RichTextEditor.SerializePlainTextContentPlugin content="Hello, this is a personalization tag: %LASTNAME%" />
</RichTextEditor.LexicalComposer>
);
};
SpecialCharacter
This plugin allows the user to insert special characters into the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalSpecialCharacter />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
Strikethrough
This plugin allows the user to strikethrough content in the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalStrikethrough />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
Subscript
This plugin allows the user to subscript content in the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalSubscript />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
Superscript
This plugin allows the user to superscript content in the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalSuperscript />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
TextBackgroundColor
This plugin allows the user to change the background color of selected content in the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalTextBackgroundColor />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
TextColor
This plugin allows the user to change the text color of selected content in the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalTextColor />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
Underline
This plugin allows the user to underline content in the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalUnderline />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
Uppercase
This plugin allows the user to convert selected content to uppercase in the editor.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.LexicalUppercase />
<RichTextEditor.Textarea>
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
The Kitchen Sink
The easiest way to insert the React-based (Lexical) WYSIWYG editor on the page with all the Camp rich text editing functionality included, is to use the ”Kitchen Sink.” This is what is shown in the demo above. Here is how that is being implemented:
import React, { useRef } from 'react';
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
import { LexicalEditor } from 'lexical';
import { $generateHtmlFromNodes } from '@lexical/html';
export const kitchenSink: StoryFn = () => {
// How to get the editor instance.
const editorRef = useRef<LexicalEditor>(null);
// HTML content to be imported.
const htmlContent = '<h1>This is a lexical Rich Text Editor</h1><p>Content goes here</p>';
return (
<RichTextEditor.LexicalKitchenSink
editorRef={editorRef}
onChange={(editorState, editor) => {
editorState.read(() => {
const htmlString = $generateHtmlFromNodes(editor, null);
// will output the HTML content of your editor as you type.
console.log(htmlString);
});
}}
>
<RichTextEditor.SerializeHtmlContentPlugin htmlContent={htmlContent} />
</RichTextEditor.LexicalKitchenSink>
);
};
Customized editors
You can also customize the editor by only pulling in the pieces you need. We export each button and piece of functionality individually as well as the groups. Here is a list of the current plugins as of Q3 2024:
- Block type (paragraph, heading, etc.)
- Font family
- Font size
- Text decoration (bold, italics, underline, strikethrough)
- Text color and background color
- Align text
- Lists
- Link
- Indent and outdent
- OnBlur
- Uppercase
- Superscript and subscript
- Emoji picker
- Special characters
- Ability to clear all text formatting
- Add image
- Add personalization
Here is an example of an editor with only specific buttons:
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
function customButtons() {
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.Toolbar>
<RichTextEditor.RichTextEditorRow>
<RichTextEditor.LexicalBlockTypes />
<RichTextEditor.LexicalBold />
<RichTextEditor.LexicalEmojiPicker />
</RichTextEditor.RichTextEditorRow>
</RichTextEditor.Toolbar>
<RichTextEditor.Textarea id="rte">
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
}
We also export common groups. Here is a list of those:
- Typography: includes block types, font family, font size
- Text styles: includes bold, italics, underline
- Text colors: includes color, background color
- Text format: includes align text, lists
- Indent: includes Indent, Outdent
- Special format: includes strikethrough, uppercase, superscript, subscript, emojis, special characters, clear formatting
Here is an example of an editor showing specific groups in multiple rows:
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
function customGroups() {
return (
<RichTextEditor.LexicalComposer>
<RichTextEditor.Toolbar>
<RichTextEditor.RichTextEditorRow>
<RichTextEditor.LexicalTypographyGroup />
<RichTextEditor.EditorExpand ariaLabel="expand the editor" />
</RichTextEditor.RichTextEditorRow>
<RichTextEditor.RichTextEditorRow>
<RichTextEditor.LexicalTextColorGroup />
<RichTextEditor.LexicalClearFormatting />
</RichTextEditor.RichTextEditorRow>
</RichTextEditor.Toolbar>
<RichTextEditor.Textarea id="rte">
<RichTextEditor.LexicalRichTextPlugin />
</RichTextEditor.Textarea>
</RichTextEditor.LexicalComposer>
);
}
Other components are also exported for maximum layout flexibility:
- Row (
<RichTextEditor.Row />
) - Divider (
<RichTextEditor.Divider />
) - Expand (
<RichTextEditor.EditorExpand />
)
Input example
The rich text editor usage is not bound solely to textareas. The rich text editor can be used with an input as well. Here is an example:
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
function RTEAsInput() {
const mockCustomObjectFields = [
{
id: '1',
name: 'contacts',
key: 'contactPersTags',
label: 'OBJECTS',
fields: [
{
id: '2',
persTag: 'CO:SomethingCool:2',
value: 'Something',
},
],
defaultCollapsed: true,
},
];
return (
<RichTextEditor.LexicalPersonalizationInput
data={mockCustomObjectFields}
userContent=""
showEmojiPicker
showPersonalizationButton
/>
);
}
Fully custom lexical editor
As the platform continues to mature, new use cases will require the creation of additional pre-built editor patterns. You can work with the Camp team to create an extremely custom rich text editing experience. Below is an example of a pattern built for the Automations team called PersonalizationEmojiPlainText
that is now available for anyone to use. It includes a Rich Text Editor that imports and exports plain text and allows for emojis and Personalization tags. If you have a custom need, reach out to us in #help-design-systems and we can work with you to make it happen.
The current props available and their descriptions for this custom component can be found here (select the tab titled RichTextEditor.PersonalizationEmojiPlainText
, beneath the example RichTextEditor
).
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
import { mockCustomObjectFields } from '...';
function personalizationEmojiPlainText() {
return (
<RichTextEditor.PersonalizationEmojiPlainText
userContent=""
showPersonalizationButton
showEmojiPicker="false"
data={mockCustomObjectFields}
onSave={(msg) => console.log('msg', msg)}
placeholder="Type '%' to add a personalization tag."
/>
);
}
Non-Lexical
You can also use the basic styled Camp elements to wire up to your own editor if you cannot use Lexical. Those are exported individually and follow the same naming conventions (minus “Lexical”). For example, the Bold button is simply <Bold />
.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
function nonLexicalPlugins() {
return (
<RichTextEditor.Toolbar>
<RichTextEditor.RichTextEditorRow>
<RichTextEditor.Bold />
<RichTextEditor.Italics />
<RichTextEditor.Underline />
<RichTextEditor.Strikethrough />
<RichTextEditor.ClearFormatting />
</RichTextEditor.RichTextEditorRow>
</RichTextEditor.Toolbar>
);
}
Custom plugins and nodes
One of the great benefits of Lexical is how flexible it is in terms of creating your own plugins. We have already created many of them for you, but below is some guidance if you need to create one yourself.
Editor context
First of all, it is important to note that since the Camp rich text editor Lexical instance is precompiled, you cannot use Lexical’s recommended approach of getting the editor instance using the useLexicalComposerContext()
hook. But there is another way to access the editor instance, which is to use a ref
. See below for an example of how to set it and pass it in to the “Kitchen Sink.”
import React, { useRef } from 'react';
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
import { LexicalEditor } from 'lexical';
export const editorRef: StoryFn = () => {
// Set the editor instance to ref.
const editorRef = useRef<LexicalEditor>(null);
// Pass it into component.
return <RichTextEditor.KitchenSink editorRef={editorRef} />;
};
Adding a custom plugin
You can add your own custom plugins to the Camp rich text editor. Below is a simplified example using the <KitchenSink />
adapted from an implementation by the Message Variables team.
import { useEffect } from 'react';
import { KitchenSink } from '@activecampaign/camp-components-rich-text-editor';
import { $generateNodesFromDOM } from '@/html';
import { $getRoot, $insertNodes, $setSelection } from '';
// Example Plugin Code
export const InsertContentPlugin = ({ htmlString, editor }): null => {
const currentEditor = editor.current;
useEffect(() => {
return currentEditor.update(() => {
const parser = new DOMParser();
const dom = parser.parseFromString(htmlString, 'text/html');
const nodes = $generateNodesFromDOM(currentEditor, dom);
$getRoot().selectStart();
$insertNodes(nodes);
// remove focus from div after nodes are inserted
$setSelection(null);
});
}, [currentEditor, htmlString]);
return null;
};
// Example Rich Text Editor Component Code
const editorRef = createRef<Editor>();
// value of rich text editor
const [lexicalValue, setLexicalValue] = useState(initialValue);
function handleChange(value): void {
const parser = new DOMParser();
const dom = parser.parseFromString(value, 'text/html');
setLexicalValue(value);
}
<LexicalKitchenSink editorRef={editorRef} onChange={handleChange}>
<InsertContentPlugin htmlString={lexicalValue} editor={editorRef} />
</LexicalKitchenSink>
Plugins can be used for all kinds of functionality, but a common one is when needing to import data from the database into the rich text editor and/or to save the editor content into a database. Importing data into the editor is called serialization and exporting is called deserialization and we have docs specifically for that here.
Once again, if you have custom plugin needs, feel free to reach out in #help-design-systems if you need the Design Systems team to help build those. Also, if they are reusable across the platform, let us know and we will help you get them into Camp so that everyone can benefit!
Adding a custom node
Lexical Nodes are basic building blocks that represent the underlying data model of the rich text editor. Some core nodes are Element (think HTML elements), Text (the foundation of all textual content), and Decorator Nodes (think custom Nodes like YouTube videos, Tweets, etc.). One of the most powerful features of Lexical is that it allows you to extend or add new Nodes.
For example, we have done that for Personalization. In the new editor, Personalization tags show up as Camp chips with the name of the tag and the lightning bolt icon. They can be clickable. For that Lexical Node, we extended Lexical’s Decorator Node, since the visual display is basically a React element. See below for the full code as of Q3 2024:
import {
$applyNodeReplacement,
DOMConversion,
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
DecoratorNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from 'lexical';
import React from 'react';
import {
PersonalizationChip,
PersonalizationChipProps,
} from '../../plugins/Personalization/PersonalizationChip';
function convertPersonalizationElement(domNode: HTMLElement): DOMConversionOutput | null {
const personalizationChipContent = domNode.textContent?.substring(
1,
domNode.textContent.length - 1
);
const personalizationValue = domNode.dataset.persTag;
if (personalizationChipContent && personalizationValue) {
const node = $createPersonalizationNode({
personalizationChipContent,
personalizationValue,
});
return {
node,
};
}
return null;
}
export class PersonalizationNode extends DecoratorNode<JSX.Element> {
__personalizationChipContent: string;
__personalizationValue: string;
static getType(): string {
return 'personalization';
}
static clone(node: PersonalizationNode): PersonalizationNode {
return new PersonalizationNode(
node.__personalizationChipContent,
node.__personalizationValue,
node.__key
);
}
static importJSON(serializedNode: SerializedPersonalizationNode): PersonalizationNode {
const personalizationDode = $createPersonalizationNode(serializedNode);
return personalizationDode;
}
exportJSON(): SerializedPersonalizationNode {
return {
personalizationChipContent: this.__personalizationChipContent,
personalizationValue: this.__personalizationValue,
type: 'personalization',
version: 1,
};
}
static importDOM(): DOMConversionMap | null {
return {
span: (domNode: HTMLElement): DOMConversion<HTMLElement> | null => {
if (!domNode.hasAttribute('data-lexical-personalization')) {
return null;
}
return {
conversion: convertPersonalizationElement,
priority: 1,
};
},
};
}
constructor(personalizationChipContent: string, personalizationValue: string, key?: NodeKey) {
super(key);
this.__personalizationChipContent = personalizationChipContent;
this.__personalizationValue = personalizationValue;
}
createDOM(): HTMLElement {
const dom = document.createElement('span');
return dom;
}
exportDOM(): DOMExportOutput {
const element = document.createElement('span');
element.dataset.LexicalPersonalization = this.__personalizationValue;
element.textContent = `%${this.__personalizationValue}%`;
return { element };
}
decorate(): JSX.Element {
return (
<PersonalizationChip
personalizationChipContent={this.__personalizationChipContent}
personalizationValue={this.__personalizationValue}
/>
);
}
updateDOM(): false {
return false;
}
}
export function $createPersonalizationNode({
personalizationChipContent,
personalizationValue,
}: PersonalizationChipProps): PersonalizationNode {
const personalizationNode = new PersonalizationNode(
personalizationChipContent,
personalizationValue
);
return $applyNodeReplacement(personalizationNode);
}
export type SerializedPersonalizationNode = Spread<PersonalizationChipProps, SerializedLexicalNode>;
Nodes follow Javascript class-based conventions. We have written a few for Camp, which you can reference if you need to create your own and Lexical documentation is also very helpful here.
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
function onSavePlainText() {
return (
<RichTextEditor.LexicalKitchenSink placeholder="See the console for your HTML output">
<RichTextEditor.OnSavePlainText onSave={(msg) => console.log('msg', msg)} />
</RichTextEditor.LexicalKitchenSink>
);
}
Custom serialization plugins
You can also write your own custom serialization plugin. As mentioned in our custom plugins documentation, you cannot get the editor instance from the lexical-recommended useLexicalComposerContext()
hook. You have to instead use a ref
to get it. The below example shows how to serialize HTML content while creating Personalization tags (note: this example implies a specific Personalization tags data structure).
import * as RichTextEditor from '@activecampaign/camp-components-rich-text-editor';
import { mockCustomObjectFields } from '...';
// Find readable match in subarrays of subarrays.
function findReadableMatch(array, persTag) {
for (const subArray of array) {
for (const item of subArray) {
if (item.persTag === persTag) {
return item;
}
}
}
return undefined;
}
// Custom Plugin to serialize HTML content while creating Personalization tags.
function SerializeHtmlContentWithPersonalizationPlugin({ htmlContent, editor, data }): null {
const currentEditor = editor.current;
if (currentEditor.getEditorState().isEmpty()) {
currentEditor.update(() => {
const personalizationDataFields = data?.map(({ fields }) => fields);
const userDataReplacingPersonalizationValues = htmlContent.replace(
/(%[\s\S]*?%)/g,
(match) => {
const contentWithoutPercentSign = match.substring(1, match.length - 1);
const readableMatch = findReadableMatch(
personalizationDataFields,
contentWithoutPercentSign
);
return readableMatch
// Our Camp Personalization plugin knows to create the tag based on the data attribute.
? `<span data-lexical-personalization data-pers-tag=${readableMatch.persTag}>%${readableMatch.value}%</span>`
: match;
}
);
const parser = new DOMParser();
const dom = parser.parseFromString(userDataReplacingPersonalizationValues, 'text/html');
const nodes = $generateNodesFromDOM(currentEditor, dom);
$getRoot().select();
$getRoot().clear();
$insertNodes(nodes);
});
}
return null;
}
function customSerializationExample() {
const editorRef = useRef<LexicalEditor>(null);
const htmlContent = '<h1>This is HTML Content</h1><p>With personalization tags: %LASTNAME%</p>';
return (
<RichTextEditor.LexicalKitchenSink
editorRef={editorRef}
customNodes={[RichTextEditor.PersonalizationNode]}
>
<RichTextEditor.LexicalPersonalization data={mockCustomObjectFields} />
<SerializeHtmlContentWithPersonalizationPlugin
htmlContent={htmlContent}
data={mockCustomObjectFields}
editor={editorRef}
/>
</RichTextEditor.LexicalKitchenSink>
);
};
Fully control serialized data
Lexical’s functions to import HTML strip out a lot of elements and attributes by default, leaving the HTML clean and secure. However, you may want to keep many of the elements and attributes that Lexical decides to strip out. This is where Lexical’s flexibility shines. It gives you the ability to extend and even create new Lexical Nodes - the basic building blocks of rich text editing (e.g., text, elements, etc.). Our Camp rich text editor Kitchen Sink ships with a node extension built in called StyledTextNode
that keeps all CSS attributes on imported elements. Also, below is an example of a plugin that ensures Lexical doesn’t strip certain elements by default (div, blockquote, and table elements) while also preserving some of the style attributes needed for those elements.
import {
DOMConversionMap,
DOMConversionOutput,
ElementNode,
$applyNodeReplacement,
NodeKey,
} from 'lexical';
// Keep elements and attributes that lexical strips out by default.
export class ExtendedElementNode extends ElementNode {
// Attributes we want to keep.
__tag: string;
__classname: string;
__style: string;
__vAlign: string;
static getType(): string {
return 'extended-element';
}
static clone(node: ExtendedElementNode): ExtendedElementNode {
return new ExtendedElementNode(
node.__key,
node.__tag,
node.__classname,
node.__style,
node.__vAlign
);
}
// Intercept lexical's importing and add a conversion function.
static importDOM(): DOMConversionMap | null {
return {
div: () => ({
conversion: convertElement,
priority: 1,
}),
blockquote: () => ({
conversion: convertElement,
priority: 1,
}),
table: () => ({
conversion: convertElement,
priority: 1,
}),
tbody: () => ({
conversion: convertElement,
priority: 1,
}),
tr: () => ({
conversion: convertElement,
priority: 1,
}),
td: () => ({
conversion: convertElement,
priority: 1,
}),
};
}
constructor(tag, classname, style, vAlign, key?: NodeKey) {
super(key);
this.__tag = tag;
this.__classname = classname;
this.__style = style;
this.__vAlign = vAlign;
}
// Create the actual element to be used and add attributes.
createDOM(): HTMLElement {
const tag = this.__tag;
const classname = this.__classname;
const style = this.__style;
const vAlign = this.__vAlign;
const dom = document.createElement(tag);
vAlign && dom.setAttribute('valign', vAlign);
classname && dom.setAttribute('class', classname);
style && dom.setAttribute('style', style);
return dom;
}
updateDOM(): false {
return false;
}
}
// Conversion function to be passed into import.
function convertElement(domNode: Node): DOMConversionOutput {
const domNode_ = domNode as HTMLElement;
const tag = domNode_.nodeName.toLowerCase();
const classname = domNode_.getAttribute('class') || '';
const style = domNode_.getAttribute('style') || '';
const vAlign = domNode_.getAttribute('valign') || '';
const node = $createExtendedElement(tag, classname, style, vAlign);
return { node };
}
// Use lexical's node replacement function to replace the node with our extended node.
export function $createExtendedElement(tag, classname, style, vAlign): ExtendedElementNode {
const extendedElementNode = new ExtendedElementNode(tag, classname, style, vAlign);
return $applyNodeReplacement(extendedElementNode);
}
Accessibility
Keyboard navigation
- tab to navigate between rich text editor items
space
orenter
to open a dropdown menu- up and down arrows to navigate between menu items within a dropdown
space
orenter
to make a selection