Creating a Plugin
This page covers Lexical plugin creation, independently of any framework or library. For those not yet familiar with Lexical it's advisable to check out the Quick Start (Vanilla JS) page.
Lexical, on the contrary to many other frameworks, doesn't define any specific interface for its plugins. The plugin in its simplest form is a function that accepts a LexicalEditor
instance, and returns a cleanup function. With access to the LexicalEditor
, plugin can extend editor via Commands, Transforms, Nodes, or other APIs.
In this guide we'll create plugin that replaces smiles (:)
, :P
, etc...) with actual emojis (using Node Transforms) and uses own graphics for emojis rendering by creating our own custom node that extends TextNode.
Preconditions
We assume that you have already implemented (see findEmoji.ts
within provided code) function that allows you to find emoji shortcodes (smiles) in text and return their position as well as some other info:
// findEmoji.ts
export type EmojiMatch = Readonly<{position: number, shortcode: string, unifiedID: string}>;
export default function findEmoji(text: string): EmojiMatch | null;
Creating own LexicalNode
Lexical as a framework provides 2 ways to customize appearance of it's content:
- By extending one of the base nodes:
ElementNode
– used as parent for other nodes, can be block level or inline.TextNode
- leaf type (so it can't have child elements) of node that contains text.DecoratorNode
- useful to insert arbitrary view (component) inside the editor.
- Via Node Overrides – useful if you want to augment behavior of the built in nodes, such as
ParagraphNode
.
As in our case we don't expect EmojiNode
to have any child nodes nor we aim to insert arbitrary component the best choice for us is to proceed with TextNode
extension.
export class EmojiNode extends TextNode {
__unifiedID: string;
static getType(): string {
return 'emoji';
}
static clone(node: EmojiNode): EmojiNode {
return new EmojiNode(node.__unifiedID, node.__key);
}
constructor(unifiedID: string, key?: NodeKey) {
const unicodeEmoji = /*...*/;
super(unicodeEmoji, key);
this.__unifiedID = unifiedID.toLowerCase();
}
/**
* DOM that will be rendered by browser within contenteditable
* This is what Lexical renders
*/
createDOM(_config: EditorConfig): HTMLElement {
const dom = document.createElement('span');
dom.className = 'emoji-node';
dom.style.backgroundImage = `url('${BASE_EMOJI_URI}/${this.__unifiedID}.png')`;
dom.innerText = this.__text;
return dom;
}
static importJSON(serializedNode: SerializedEmojiNode): EmojiNode {
return $createEmojiNode(serializedNode.unifiedID);
}
exportJSON(): SerializedEmojiNode {
return {
...super.exportJSON(),
type: 'emoji',
unifiedID: this.__unifiedID,
};
}
}
Example above represents absolute minimal setup of the custom node that extends TextNode
. Let's look at the key elements here:
constructor(...)
+ class props – Allows us to store custom data within nodes at runtime as well as accept custom parameters.getType()
&clone(...)
– methods allow Lexical to correctly identify node type as well as being able to clone it correctly as we may want to customize cloning behavior.importJSON(...)
&exportJSON()
– define how our data will be serialized / deserialized to/from Lexical state. Here you define your node presentation in state.createDOM(...)
– defines DOM that will be rendered by Lexical
Creating Node Transform
Transforms allow efficient response to changes to the EditorState
, and so user input. Their efficiency comes from the fact that transforms are executed before DOM reconciliation (the most expensive operation in Lexical's life cycle).
Additionally it's important to mention that Lexical Node Transforms are smart enough to allow you not to think about any side effects of the modifications done within transform or interdependencies with other transform listeners. Rule of thumb here is that changes done to the node within a particular transform will trigger rerun of the other transforms till no changes are made to the EditorState
. Read more about it in Transform heuristic.
In our example we have simple transform that executes the following business logic:
- Attempt to transform
TextNode
. It will be run on any change toTextNode
's. - Check if emoji shortcodes (smiles) are present in the text within
TextNode
. Skip if none. - Split
TextNode
into 2 or 3 pieces (depending on the position of the shortcode in text) so target emoji shortcode has own dedicatedTextNode
- Replace emoji shortcode
TextNode
withEmojiNode
import {LexicalEditor, TextNode} from 'lexical';
import {$createEmojiNode} from './EmojiNode';
import findEmoji from './findEmoji';
function textNodeTransform(node: TextNode): void {
if (!node.isSimpleText() || node.hasFormat('code')) {
return;
}
const text = node.getTextContent();
// Find only 1st occurrence as transform will be re-run anyway for the rest
// because newly inserted nodes are considered to be dirty
const emojiMatch = findEmoji(text);
if (emojiMatch === null) {
return;
}
let targetNode;
if (emojiMatch.position === 0) {
// First text chunk within string, splitting into 2 parts
[targetNode] = node.splitText(
emojiMatch.position + emojiMatch.shortcode.length,
);
} else {
// In the middle of a string
[, targetNode] = node.splitText(
emojiMatch.position,
emojiMatch.position + emojiMatch.shortcode.length,
);
}
const emojiNode = $createEmojiNode(emojiMatch.unifiedID);
targetNode.replace(emojiNode);
}
export function registerEmoji(editor: LexicalEditor): () => void {
// We don't use editor.registerUpdateListener here as alternative approach where we rely
// on update listener is highly discouraged as it triggers an additional render (the most expensive lifecycle operation).
return editor.registerNodeTransform(TextNode, textNodeTransform);
}
Putting it all together
Finally we configure Lexical instance with our newly created plugin by registering EmojiNode
within editor config and executing registerEmoji(editor)
plugin bootstrap function. Here for that sake of simplicity we assume that the plugin picks its own approach for CSS & Static Assets distribution (if any), Lexical doesn't enforce any rules on that.
Refer to Quick Start (Vanilla JS) Example to fill the gaps in this pseudocode.
import {createEditor} from 'lexical';
import {mergeRegister} from '@lexical/utils';
/* ... */
import {EmojiNode} from './emoji-plugin/EmojiNode';
import {registerEmoji} from './emoji-plugin/EmojiPlugin';
const initialConfig = {
/* ... */
// Register our newly created node
nodes: [EmojiNode, /* ... */],
};
const editor = createEditor(config);
const editorRef = document.getElementById('lexical-editor');
editor.setRootElement(editorRef);
// Registering Plugins
mergeRegister(
/* ... */
registerEmoji(editor), // Our plugin
);