facebook / lexical

Lexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.
https://lexical.dev
MIT License
17.5k stars 1.45k forks source link

Bug: Inserting a paragraph after a table within a custom ElementNode causes an infinite loop #5962

Open noahnu opened 3 weeks ago

noahnu commented 3 weeks ago

Lexical version: 0.14.5

Steps To Reproduce

  1. Create an element node, e.g. LayoutNode.
  2. Add the element to the editor state.
  3. Add a table (from the lexical table plugin) to LayoutNode.
  4. Using the keyboard, navigate to the end of the table using right arrow and down arrow keys. Once the caret is on the right outer edge of the table, hit Down Arrow Key and then Enter key.
  5. Lexical gets stuck in an infinite loop in https://github.com/facebook/lexical/blob/db7b242c187aec21e6db9cfbfea94d8adf9fcf8a/packages/lexical/src/LexicalSelection.ts#L2719-L2721. It loops/traverses up to the root and since the root is not a block element, it executes splitNodeAtPoint which calls node.getParent() which returns the root again.

Link to code example:

LayoutNode implementation:

LayoutNode implementation ```ts import { $applyNodeReplacement, type DOMConversionFn, type DOMConversionMap, type EditorConfig, ElementNode, type LexicalNode, type SerializedElementNode, type Spread, } from 'lexical'; export type SerializedLayoutNode = Spread< { version: 1; }, SerializedElementNode >; const convertDivElement: DOMConversionFn = element => { if (!(element instanceof HTMLDivElement)) return null; const node = $createLayoutNode(); return { node, }; }; export class LayoutNode extends ElementNode { static getType(): string { return 'layout'; } static clone(node: LayoutNode): LayoutNode { return new LayoutNode(node.__key); } static importJSON(serializedNode: SerializedLayoutNode): LayoutNode { const node = $createLayoutNode(); return node; } createDOM(_config: EditorConfig): HTMLElement { const element = document.createElement('div'); return element; } exportJSON(): SerializedLayoutNode { return { ...super.exportJSON(), type: this.getType(), version: 1, }; } static importDOM(): DOMConversionMap | null { return { div: node => { return { conversion: convertDivElement, }; }, }; } updateDOM(prevNode: LayoutNode): boolean { return false; } canBeEmpty(): boolean { return true; } canIndent(): boolean { return false; } isShadowRoot(): boolean { return false; } } export function $isLayoutNode(node: LexicalNode | null | undefined): node is LayoutNode { return node instanceof LayoutNode; } export function $createLayoutNode(): LayoutNode { return $applyNodeReplacement(new LayoutNode()); } ```

The current behavior

Infinite loop trying to insert a paragraph.

The expected behavior

A paragraph should be created after the table node, within the LayoutNode.


I believe I can workaround the issue by defining my own "select" method on the element node such that when the user tries to select the element node, it inserts a paragraph node if needed.

    select(anchorOffset?: number, focusOffset?: number): RangeSelection {
        const selection = super.select(anchorOffset, focusOffset);

        if (selection.anchor.key === this.__key || selection.focus.key === this.__key) {
            // If the current node is selected, select either the first child or last child,
            // but don't allow selection of the current node

            if (anchorOffset === 0) {
                // Insert paragraph at the beginning if needed
                const firstChild = this.getFirstChild();
                if (
                    !firstChild ||
                    (!$isParagraphNode(firstChild) &&
                        !$isTextNode(firstChild) &&
                        !firstChild.isInline())
                ) {
                    this.splice(0, 0, [$applyNodeReplacement($createParagraphNode())]);
                }

                return this.selectStart();
            }

            // Insert paragraph at the end if needed
            const lastChild = this.getLastChild();
            if (
                !lastChild ||
                (!$isParagraphNode(lastChild) && !$isTextNode(lastChild) && !lastChild.isInline())
            ) {
                this.append($applyNodeReplacement($createParagraphNode()));
            }

            return this.selectEnd();
        }

        return selection;
    }

If the element node cannot be selected, we won't enter that infinite loop