This package provides essential utility functions for the Lexical rich text editor framework, offering a comprehensive set of DOM manipulation helpers, tree traversal algorithms, and editor state management tools.
—
Pending
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Pending
The risk profile of this skill
Focused utility modules providing selection marking, DOM node positioning, function merging, and CSS utilities. These are specialized tools exported as default exports from individual modules, each solving specific common problems in rich text editor development.
Combines multiple cleanup functions into a single function that executes them in reverse order (LIFO), commonly used with React's useEffect and Lexical's registration functions.
/**
* Returns a function that will execute all functions passed when called. It is generally used
* to register multiple lexical listeners and then tear them down with a single function call, such
* as React's useEffect hook.
*
* The order of cleanup is the reverse of the argument order. Generally it is
* expected that the first "acquire" will be "released" last (LIFO order),
* because a later step may have some dependency on an earlier one.
*
* @param func - An array of cleanup functions meant to be executed by the returned function.
* @returns the function which executes all the passed cleanup functions.
*/
function mergeRegister(...func: Array<() => void>): () => void;Usage Examples:
import { mergeRegister } from "@lexical/utils";
// Basic usage with React useEffect
useEffect(() => {
return mergeRegister(
editor.registerCommand(SOME_COMMAND, commandHandler),
editor.registerUpdateListener(updateHandler),
editor.registerTextContentListener(textHandler)
);
}, [editor]);
// Manual cleanup management
const cleanupFunctions: Array<() => void> = [];
// Register various listeners
cleanupFunctions.push(editor.registerCommand('INSERT_TEXT', handleInsertText));
cleanupFunctions.push(editor.registerCommand('DELETE_TEXT', handleDeleteText));
cleanupFunctions.push(editor.registerNodeTransform(TextNode, handleTextTransform));
// Create single cleanup function
const cleanup = mergeRegister(...cleanupFunctions);
// Later, clean up everything at once
cleanup();
// Advanced pattern with conditional registration
function setupEditor(editor: LexicalEditor, features: EditorFeatures) {
const registrations: Array<() => void> = [];
// Always register core handlers
registrations.push(
editor.registerUpdateListener(handleUpdate),
editor.registerCommand('FOCUS', handleFocus)
);
// Conditionally register feature handlers
if (features.autoSave) {
registrations.push(
editor.registerTextContentListener(handleAutoSave)
);
}
if (features.collaboration) {
registrations.push(
editor.registerCommand('COLLAB_UPDATE', handleCollabUpdate),
editor.registerMutationListener(ElementNode, handleMutation)
);
}
if (features.spellCheck) {
registrations.push(
setupSpellChecker(editor)
);
}
return mergeRegister(...registrations);
}
// Plugin pattern
class CustomPlugin {
private cleanupFn: (() => void) | null = null;
initialize(editor: LexicalEditor) {
this.cleanupFn = mergeRegister(
this.registerCommands(editor),
this.registerTransforms(editor),
this.registerListeners(editor)
);
}
destroy() {
if (this.cleanupFn) {
this.cleanupFn();
this.cleanupFn = null;
}
}
private registerCommands(editor: LexicalEditor) {
return mergeRegister(
editor.registerCommand('CUSTOM_COMMAND_1', this.handleCommand1),
editor.registerCommand('CUSTOM_COMMAND_2', this.handleCommand2)
);
}
private registerTransforms(editor: LexicalEditor) {
return mergeRegister(
editor.registerNodeTransform(CustomNode, this.transformNode),
editor.registerNodeTransform(TextNode, this.transformText)
);
}
private registerListeners(editor: LexicalEditor) {
return editor.registerUpdateListener(this.handleUpdate);
}
}Creates visual selection markers when the editor loses focus, maintaining selection visibility for user experience.
/**
* Place one or multiple newly created Nodes at the current selection. Multiple
* nodes will only be created when the selection spans multiple lines (aka
* client rects).
*
* This function can come useful when you want to show the selection but the
* editor has been focused away.
*/
function markSelection(
editor: LexicalEditor,
onReposition?: (node: Array<HTMLElement>) => void
): () => void;Usage Examples:
import { markSelection } from "@lexical/utils";
// Basic selection marking
const removeSelectionMark = markSelection(editor);
// Custom repositioning handler
const removeSelectionMark = markSelection(editor, (domNodes) => {
domNodes.forEach(node => {
// Custom styling for selection markers
node.style.backgroundColor = 'rgba(0, 123, 255, 0.3)';
node.style.border = '2px solid #007bff';
node.style.borderRadius = '3px';
});
});
// Focus management with selection marking
class FocusManager {
private editor: LexicalEditor;
private removeSelectionMark: (() => void) | null = null;
constructor(editor: LexicalEditor) {
this.editor = editor;
this.setupFocusHandling();
}
private setupFocusHandling() {
const rootElement = this.editor.getRootElement();
if (!rootElement) return;
rootElement.addEventListener('blur', () => {
// Mark selection when editor loses focus
this.removeSelectionMark = markSelection(this.editor, (nodes) => {
nodes.forEach(node => {
node.style.backgroundColor = 'rgba(255, 235, 59, 0.3)';
node.classList.add('selection-marker');
});
});
});
rootElement.addEventListener('focus', () => {
// Remove selection marks when editor regains focus
if (this.removeSelectionMark) {
this.removeSelectionMark();
this.removeSelectionMark = null;
}
});
}
destroy() {
if (this.removeSelectionMark) {
this.removeSelectionMark();
}
}
}
// Integration with modal dialogs
function showModalWithSelectionPreserved(editor: LexicalEditor) {
// Mark selection before showing modal
const removeSelectionMark = markSelection(editor);
const modal = document.createElement('div');
modal.className = 'modal';
// Modal close handler
const closeModal = () => {
removeSelectionMark(); // Clean up selection markers
modal.remove();
};
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeModal();
}
});
document.body.appendChild(modal);
}Positions DOM nodes at a Range's location with automatic repositioning when the DOM changes, useful for highlighting and overlays.
/**
* Place one or multiple newly created Nodes at the passed Range's position.
* Multiple nodes will only be created when the Range spans multiple lines (aka
* client rects).
*
* This function can come particularly useful to highlight particular parts of
* the text without interfering with the EditorState, that will often replicate
* the state across collab and clipboard.
*
* This function accounts for DOM updates which can modify the passed Range.
* Hence, the function return to remove the listener.
*/
function positionNodeOnRange(
editor: LexicalEditor,
range: Range,
onReposition: (node: Array<HTMLElement>) => void
): () => void;Usage Examples:
import { positionNodeOnRange } from "@lexical/utils";
// Basic text highlighting
function highlightRange(editor: LexicalEditor, range: Range) {
const removeHighlight = positionNodeOnRange(editor, range, (nodes) => {
nodes.forEach(node => {
node.style.backgroundColor = 'yellow';
node.style.opacity = '0.5';
node.classList.add('highlight');
});
});
// Return cleanup function
return removeHighlight;
}
// Comment annotation system
class CommentSystem {
private activeComments = new Map<string, () => void>();
addComment(editor: LexicalEditor, range: Range, commentId: string, text: string) {
const removePositioning = positionNodeOnRange(editor, range, (nodes) => {
nodes.forEach(node => {
node.style.backgroundColor = 'rgba(255, 193, 7, 0.3)';
node.style.borderBottom = '2px solid #ffc107';
node.style.cursor = 'pointer';
node.title = text;
node.dataset.commentId = commentId;
// Click handler to show comment
node.addEventListener('click', () => this.showComment(commentId));
});
});
this.activeComments.set(commentId, removePositioning);
}
removeComment(commentId: string) {
const removePositioning = this.activeComments.get(commentId);
if (removePositioning) {
removePositioning();
this.activeComments.delete(commentId);
}
}
clearAllComments() {
this.activeComments.forEach(removePositioning => removePositioning());
this.activeComments.clear();
}
private showComment(commentId: string) {
// Show comment UI
console.log(`Show comment ${commentId}`);
}
}
// Search result highlighting
class SearchHighlighter {
private highlights: Array<() => void> = [];
highlightSearchResults(editor: LexicalEditor, searchTerm: string) {
this.clearHighlights();
const rootElement = editor.getRootElement();
if (!rootElement) return;
const walker = document.createTreeWalker(
rootElement,
NodeFilter.SHOW_TEXT
);
const ranges: Range[] = [];
let textNode: Text | null;
// Find all text nodes containing search term
while (textNode = walker.nextNode() as Text) {
const text = textNode.textContent || '';
const index = text.toLowerCase().indexOf(searchTerm.toLowerCase());
if (index !== -1) {
const range = document.createRange();
range.setStart(textNode, index);
range.setEnd(textNode, index + searchTerm.length);
ranges.push(range);
}
}
// Highlight each range
ranges.forEach((range, index) => {
const removeHighlight = positionNodeOnRange(editor, range, (nodes) => {
nodes.forEach(node => {
node.style.backgroundColor = '#ffeb3b';
node.style.color = '#000';
node.style.fontWeight = 'bold';
node.classList.add('search-highlight');
node.dataset.searchIndex = index.toString();
});
});
this.highlights.push(removeHighlight);
});
}
clearHighlights() {
this.highlights.forEach(removeHighlight => removeHighlight());
this.highlights = [];
}
}
// Spell check underlines
function addSpellCheckUnderline(editor: LexicalEditor, range: Range, suggestions: string[]) {
return positionNodeOnRange(editor, range, (nodes) => {
nodes.forEach(node => {
node.style.borderBottom = '2px wavy red';
node.style.cursor = 'pointer';
node.title = `Suggestions: ${suggestions.join(', ')}`;
// Right-click context menu
node.addEventListener('contextmenu', (e) => {
e.preventDefault();
showSpellCheckMenu(e.clientX, e.clientY, suggestions);
});
});
});
}Maintains visible selection when the editor loses focus by automatically switching to selection marking.
/**
* Maintains visible selection display even when editor loses focus
*/
function selectionAlwaysOnDisplay(
editor: LexicalEditor
): () => void;Usage Examples:
import { selectionAlwaysOnDisplay } from "@lexical/utils";
// Basic usage - always show selection
const removeAlwaysDisplay = selectionAlwaysOnDisplay(editor);
// Clean up when component unmounts
useEffect(() => {
const cleanup = selectionAlwaysOnDisplay(editor);
return cleanup;
}, [editor]);
// Conditional selection display
class EditorManager {
private removeSelectionDisplay: (() => void) | null = null;
constructor(private editor: LexicalEditor) {}
enablePersistentSelection() {
if (!this.removeSelectionDisplay) {
this.removeSelectionDisplay = selectionAlwaysOnDisplay(this.editor);
}
}
disablePersistentSelection() {
if (this.removeSelectionDisplay) {
this.removeSelectionDisplay();
this.removeSelectionDisplay = null;
}
}
destroy() {
this.disablePersistentSelection();
}
}
// Multi-editor setup with selective persistent selection
function setupMultipleEditors(editors: LexicalEditor[], persistentSelectionIndex: number) {
const cleanupFunctions: Array<() => void> = [];
editors.forEach((editor, index) => {
if (index === persistentSelectionIndex) {
// Only one editor maintains persistent selection
cleanupFunctions.push(selectionAlwaysOnDisplay(editor));
}
// Other setup for each editor
cleanupFunctions.push(
editor.registerUpdateListener(handleUpdate),
editor.registerCommand('FOCUS', () => {
// Switch persistent selection to focused editor
// Implementation would switch which editor has persistent selection
})
);
});
return mergeRegister(...cleanupFunctions);
}These specialized utilities are often used together in complex editor scenarios:
import {
mergeRegister,
markSelection,
positionNodeOnRange,
selectionAlwaysOnDisplay
} from "@lexical/utils";
// Complete rich text editor setup
function setupAdvancedEditor(editor: LexicalEditor) {
const registrations: Array<() => void> = [];
// Persistent selection display
registrations.push(selectionAlwaysOnDisplay(editor));
// Selection-based tools
registrations.push(
editor.registerCommand('SHOW_TOOLTIP', (payload) => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const range = createDOMRange(selection);
const removeTooltip = positionNodeOnRange(editor, range, (nodes) => {
const tooltip = document.createElement('div');
tooltip.textContent = payload.text;
tooltip.style.backgroundColor = '#333';
tooltip.style.color = 'white';
tooltip.style.padding = '8px';
tooltip.style.borderRadius = '4px';
tooltip.style.position = 'absolute';
tooltip.style.zIndex = '1000';
nodes[0]?.appendChild(tooltip);
});
// Auto-remove after delay
setTimeout(removeTooltip, 3000);
}
return true;
})
);
// Other editor features...
return mergeRegister(...registrations);
}