Creating an Extension
An extension keeps a feature's nodes, configuration, dependencies, and behavior
together. Adding it to an editor includes everything the feature needs, and the
editor cleans up its registrations on disposal. It works with both
buildEditorFromExtensions and LexicalExtensionComposer.
This guide builds EmojiExtension: a node transform replaces shortcodes such as
:) and :smiley: with a custom EmojiNode that displays an emoji image. The
runnable example includes the lookup data and CSS,
and loads the displayed images on demand. The code blocks below are read directly
from named regions in that example.
Finding emoji shortcodes
The example's findEmoji.ts uses emoji-datasource-facebook to find the first
space-delimited shortcode in a string. Its result has this shape:
export type EmojiMatch = Readonly<{
position: number;
shortcode: string;
unifiedID: string;
}>;
For example, findEmoji('Hello :)') returns the match position, :), and a
hexadecimal Unicode code point ID used to find the corresponding image.
Creating a custom node
An emoji is text with a custom appearance, so extend TextNode. Use ElementNode
for nodes with children, or DecoratorNode for arbitrary embedded UI. See
Nodes for those alternatives.
Declare the __unifiedID property in a $config JSON schema.
Lexical then supplies cloning and JSON serialization, including inherited text
properties, without handwritten clone, importJSON, or exportJSON methods.
Keep the node, its factory, and the extension together in EmojiExtension.ts:
import {
defineImportRule,
DOMImportExtension,
domOverride,
DOMRenderExtension,
sel,
} from '@lexical/html';
import {version as emojiVersion} from 'emoji-datasource-facebook/package.json';
import {
$create,
$isTextNode,
configExtension,
defineExtension,
isHTMLElement,
nodeSchema,
stringValue,
TextNode,
withField,
} from 'lexical';
import findEmoji from './findEmoji';
const emojiNodeSchema = nodeSchema<EmojiNode>()({
unifiedID: withField(stringValue(), {field: '__unifiedID'}),
});
export class EmojiNode extends TextNode {
__unifiedID: string = '';
$config() {
return this.config('emoji', {
extends: TextNode,
json: emojiNodeSchema,
});
}
getUnifiedID(): string {
return this.getLatest().__unifiedID;
}
setUnifiedID(unifiedID: string): this {
const self = this.getWritable();
self.__unifiedID = unifiedID.toLowerCase();
return self;
}
}
export function $createEmojiNode(unifiedID: string): EmojiNode {
const text = String.fromCodePoint(
...unifiedID.split('-').map(value => parseInt(value, 16)),
);
return $create(EmojiNode)
.setTextContent(text)
.setMode('token')
.setUnifiedID(unifiedID);
}
The inherited TextNode constructor accepts no arguments, which allows $create
and the generated deserializer to construct the node. The factory sets its text,
its ID, and token mode: an emoji is deleted as a unit, and typing beside it
creates regular text.
withField maps the top-level JSON property unifiedID to __unifiedID on the
node, preserving the example's serialized format. stringValue() validates
imported values and defaults missing or invalid values to an empty string. The
schema also carries the property across clones. Getters use getLatest() and
setters use getWritable() to respect Lexical's immutable editor states.
The node inherits TextNode's DOM creation, updates, and HTML export. The
extension below adds the image through a $decorateDOM override in
DOMRenderExtension, which only runs for the live editor. Its separate
$exportDOM override adds a stable data-emoji-id attribute for HTML import.
Copying or exporting HTML preserves the Unicode text and formatting without
loading images or including the live editor's classes and loading attributes.
The native Unicode text stays visible until the image loads successfully. An unknown ID or a failed request therefore keeps the native emoji visible. The URL check prevents a late response for an old ID from replacing the current image.
Only the loaded class hides the Unicode text; it remains in the document for selection, copying, and serialization:
.emoji-node {
caret-color: #050505;
}
.emoji-node-loaded {
color: transparent;
background-size: 1em 1em;
display: inline-block;
vertical-align: top;
width: 1em;
height: 1em;
}
The example requests only the images it displays instead of bundling the entire
PNG collection. It reads the CDN version from the installed emoji package's
package.json, keeping images and lookup data on the same version. To host images
yourself, change BASE_EMOJI_URI in EmojiExtension.ts to your own URL.
Creating a node transform
A node transform runs before DOM reconciliation, within the update that changed a node. Use it to replace shortcodes without starting a second update from an update listener.
The transform below:
- Skips custom text nodes, token nodes, and inline code.
- Finds a shortcode and splits it out of the surrounding text.
- Replaces that part with an
EmojiNode, preserving text formatting and styles.
The remaining text is dirty after splitting, so Lexical runs transforms on it
again to find additional shortcodes. The isSimpleText() guard excludes
EmojiNode, so replacements do not transform themselves in a loop.
The next fragment of EmojiExtension.ts defines the transform and image helper
using the node and imports above:
const BASE_EMOJI_URI = `https://cdn.jsdelivr.net/npm/emoji-datasource-facebook@${emojiVersion}/img/facebook/64`;
function applyEmojiImage(dom: HTMLElement, unifiedID: string): void {
const url = `${BASE_EMOJI_URI}/${encodeURIComponent(unifiedID.toLowerCase())}.png`;
const backgroundImage = `url('${url}')`;
if (dom.dataset.emojiUrl === url) {
// TextNode may have updated inline styles. Restore the loaded background
// without clearing it or starting another preload for the same image.
if (dom.classList.contains('emoji-node-loaded')) {
dom.style.backgroundImage = backgroundImage;
}
return;
}
// Keep the native emoji visible while loading, including for missing images.
dom.classList.remove('emoji-node-loaded');
dom.style.backgroundImage = '';
dom.dataset.emojiUrl = url;
const image = dom.ownerDocument.createElement('img');
image.onload = () => {
// A different ID may have been assigned while this image was loading.
if (dom.dataset.emojiUrl === url) {
dom.style.backgroundImage = backgroundImage;
dom.classList.add('emoji-node-loaded');
}
};
image.src = url;
}
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)
.setFormat(targetNode.getFormat())
.setStyle(targetNode.getStyle());
targetNode.replace(emojiNode);
}
The decorator remembers the URL on the DOM element. When an update changes inline styles, it restores the loaded background without clearing the image or starting another preload. Changing the emoji ID starts a new load and shows the native text until it succeeds.
Preserving emojis in HTML
Unicode text alone does not identify an EmojiNode on import. The extension's
$exportDOM override calls $next() to keep the usual text formatting, then adds
data-emoji-id to the exported element. The same output is used for HTML export
and copying to the clipboard.
EmojiImportRule matches that attribute and delegates to $next() first, so the
normal rules import text and nested formatting. It restores an EmojiNode only
when the result is a single text node whose Unicode code points agree with the
attribute. Comparing against the actual text avoids parsing untrusted code point
values. Invalid or mismatched attributes fall back to the ordinary imported
content. The new node retains the imported formatting and styles.
Register the import rule and both DOM hooks in the same extension:
const EmojiImportRule = defineImportRule({
$import(_context, element, $next) {
// Preserve the usual text import behavior, including nested formatting.
const nodes = $next();
const node = nodes[0];
if (nodes.length === 1 && $isTextNode(node)) {
const text = node.getTextContent();
const unifiedID = Array.from(text, char =>
char.codePointAt(0)!.toString(16).padStart(4, '0'),
).join('-');
const attribute = element.getAttribute('data-emoji-id');
// Only restore the emoji when the attribute agrees with the actual text.
// Invalid or mismatched attributes leave the imported content unchanged.
if (attribute !== null && unifiedID === attribute.toLowerCase()) {
return [
$create(EmojiNode)
.setUnifiedID(unifiedID)
.setTextContent(text)
.setMode('token')
.setFormat(node.getFormat())
.setStyle(node.getStyle()),
];
}
}
return nodes;
},
match: sel.any().attr('data-emoji-id', true),
name: '@lexical/examples/emoji',
});
export const EmojiExtension = defineExtension({
dependencies: [
configExtension(DOMImportExtension, {rules: [EmojiImportRule]}),
configExtension(DOMRenderExtension, {
overrides: [
domOverride([EmojiNode], {
$decorateDOM(node, _prevNode, dom) {
dom.classList.add('emoji-node');
applyEmojiImage(dom, node.getUnifiedID());
},
$exportDOM(node, $next) {
const output = $next();
if (isHTMLElement(output.element)) {
output.element.setAttribute('data-emoji-id', node.getUnifiedID());
}
return output;
},
}),
],
}),
],
name: '@lexical/examples/Emoji',
nodes: () => [EmojiNode],
register(editor) {
return editor.registerNodeTransform(TextNode, $textNodeTransform);
},
});
nodes registers the custom node before the editor is initialized. register
installs the transform and returns its cleanup function. Consumers only add
EmojiExtension; they do not separately register EmojiNode or call a bootstrap
function.
Putting it all together
Add the feature to a root extension:
import {ClipboardDOMImportExtension} from '@lexical/clipboard';
import {HistoryExtension} from '@lexical/history';
import {
$generateHtmlFromNodes,
$generateNodesFromDOMViaExtension,
} from '@lexical/html';
import {RichTextExtension} from '@lexical/rich-text';
import {
$getRoot,
configExtension,
defineExtension,
mergeRegister,
registerEventListener,
} from 'lexical';
import {EmojiExtension} from './emoji-plugin/EmojiExtension';
import $prepopulatedRichText from './prepopulatedRichText';
export const AppExtension = defineExtension({
$initialEditorState: $prepopulatedRichText,
dependencies: [
RichTextExtension,
ClipboardDOMImportExtension,
configExtension(HistoryExtension, {delay: 300}),
EmojiExtension,
],
name: '@lexical/examples/vanilla-js-plugin',
namespace: 'Vanilla JS Emoji Demo',
register(editor) {
const stateRef =
document.querySelector<HTMLTextAreaElement>('#lexical-state')!;
const html = document.querySelector<HTMLTextAreaElement>('#html')!;
return mergeRegister(
editor.registerUpdateListener(({editorState}) => {
stateRef.value = JSON.stringify(editorState.toJSON(true), null, 2);
}),
registerEventListener(
document.getElementById('export-html')!,
'click',
() => {
html.value = editor.read('latest', () =>
$generateHtmlFromNodes(editor),
);
},
),
registerEventListener(
document.getElementById('import-html')!,
'click',
() => {
const dom = new DOMParser().parseFromString(html.value, 'text/html');
editor.update(() => {
const nodes = $generateNodesFromDOMViaExtension(dom);
$getRoot()
.clear()
.append(...nodes);
});
},
),
);
},
});
Then build and attach the editor:
import './styles.css';
import {buildEditorFromExtensions, HMRExtension} from '@lexical/extension';
import {configExtension} from 'lexical';
import {AppExtension} from './AppExtension';
const editor = buildEditorFromExtensions(
AppExtension,
configExtension(HMRExtension, {hot: import.meta.hot ?? null}),
);
editor.setRootElement(document.getElementById('lexical-editor'));
// Accept Vite updates; HMRExtension preserves editor state.
// In an application, also call dispose() when removing the editor permanently.
if (import.meta.hot) {
import.meta.hot.accept();
import.meta.hot.dispose(() => editor.dispose());
}
Use the editable element from Quick Start. In React, pass the
same root extension to LexicalExtensionComposer instead of building and
attaching the editor yourself. No React-specific emoji plugin is needed.
In the example below, type a shortcode, then use Export HTML and Import
HTML to try the round trip. The markup contains the Unicode text and
data-emoji-id; Editor state JSON confirms that import restores the emoji
type, unifiedID, and token mode. HTML pasted from another application goes
through the same import rule via ClipboardDOMImportExtension.
To add data to an existing node without defining a subclass, continue with Adding Data to Nodes.
Publishing your extension
If the extension ships as its own npm package, declare lexical and any
@lexical/* packages you import as peerDependencies, plus devDependencies
for your build and tests. Applications must resolve
one copy of each Lexical package.
For configurable features, outputs, and dependency configuration, continue with Defining Extensions.