CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-lexical--utils

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
Quality

Pending

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Pending

The risk profile of this skill

Overview
Eval results
Files

specialized-utilities.mddocs/

Specialized Utilities

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.

Capabilities

Function Merging

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);
  }
}

Selection Marking

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);
}

DOM Node Positioning on Range

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);
      });
    });
  });
}

Selection Always on Display

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);
}

Integration Patterns

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);
}

docs

dom-manipulation.md

editor-state.md

file-handling.md

index.md

specialized-utilities.md

tree-traversal.md

tile.json