3

I want to find text* in Lexical JS and apply a highlight style to all matches.

import {useLexicalComposerContext} from "@lexical/react/LexicalComposerContext";
import {$createRangeSelection, $getRoot, $isParagraphNode, ParagraphNode} from "lexical";


const HighlightSearchButton = () => {
    const [editor] = useLexicalComposerContext();

    const handleClick = async () => {
        editor.update(() => {
            const searchStr = 'Hello';
            const regex = new RegExp(searchStr, 'gi')
            const children = $getRoot().getChildren();
            for (const child of children) {
                if (!$isParagraphNode(child)) continue;
                const paragraphNode = child as ParagraphNode;
                const text = child.getTextContent();
                const indexes = [];
                let result;
                while (result = regex.exec(text)) {
                    indexes.push(result.index);
                }
                for (const index of indexes) {
                    const selection = $createRangeSelection();
                    selection.anchor.key = paragraphNode.getKey(),
                    selection.anchor.offset = index,
                    selection.focus.key = paragraphNode.getKey(),
                    selection.focus.offset = index + searchStr.length
                    // Note: This makes the entire paragraph bold
                    selection.formatText('bold'); // Note: actually want to apply a css style
                }
            }
        });
    };

    return <button onClick={handleClick}>Highlight Search</button>
};

export default HighlightSearchButton;

I'm trying to use $createRangeSelection, but if I give it a paragraph node key I don't seem to have access to the anchor/focus offsets of the text.

As I've pulled out the full text with getTextContent() I don't know what text nodes the selection range would apply to. Also If there are multiple text nodes already in the paragraph, ie bold and italics etc., I'm not sure how to manage keeping track of those.

I did have a quick look at using node transforms, again I'm not sure that'll work for what I need. It looks like you'd just get the text node that's being edited. If the node is split with existing formatting, I don't know if it'd provide all the text I need.

* I actually want to find grammatical errors with write-good but this is a simpler example to demonstrate.

OrderAndChaos
  • 3,547
  • 2
  • 30
  • 57

2 Answers2

3

The only way I've found so far is to handle the text insertion manually. It'll wipe out any existing styles, so the editor state is stored, and the editor is locked until the search is toggled off.

import {useLexicalComposerContext} from "@lexical/react/LexicalComposerContext";
import {
    $createRangeSelection,
    $createTextNode,
    $getRoot,
    $isParagraphNode,
    EditorState,
    ParagraphNode,
    TextNode
} from "lexical";
import {useState} from "react";

const HighlightSearchButton = () => {
    const [editor] = useLexicalComposerContext();
    // Note: The script will wipe out all existing styles so we save the editor state
    const [lastState, setLastState] = useState<EditorState | null>(null);

    const handleClick = async () => {
        // Note: Revert to last known editor state if it's stored
        if (lastState !== null) {
            editor.setEditorState(lastState);
            setLastState(null);
            editor.setEditable(true);
            return;
        }

        // Note: While search is active disable editing so the lastState will remain in sync
        editor.setEditable(false);
        setLastState(editor.getEditorState());

        editor.update(() => {
            const searchStr = 'Hello';
            const strLength = searchStr.length;
            const regex = new RegExp(searchStr, 'gi')
            const children = $getRoot().getChildren();
            for (const child of children) {
                if (!$isParagraphNode(child)) continue;
                const paragraphNode = child as ParagraphNode;
                const text = child.getTextContent();

                const indexes = [];
                let result;
                while (result = regex.exec(text)) indexes.push(result.index);

                if (!indexes.length) continue;
                paragraphNode.clear();

                const chunks = [];
                if(indexes[0] !== 0)
                    chunks.push(0);
                for (const index of indexes)
                    chunks.push(index, index + strLength);
                if(chunks.at(-1) !== text.length)
                    chunks.push(text.length);

                for (let i = 0; i < chunks.length - 1; i++){
                    const start = chunks[i]
                    const end = chunks[i + 1]
                    const textNode = $createTextNode(text.slice(start, end));
                    if(indexes.includes(chunks[i])) {
                        textNode.setStyle('background-color: #22f3bc');
                    }
                    paragraphNode.append(textNode);
                }
            }
        });
    };

    return <button onClick={handleClick}>Highlight Search</button>
};

export default HighlightSearchButton;


OrderAndChaos
  • 3,547
  • 2
  • 30
  • 57
1

Probably a bit late for this question, but in case anyone else is looking for the same sort of thing, this is what worked in my use case:

textNode.select(match.index, match.index + keyWord.length)
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'highlight')
  • Have you tested that this works across existing styles? For example, if you try to highlight from a bold piece of text to an italicised piece of text. – OrderAndChaos Mar 11 '23 at 22:46
  • 1
    @OrderAndChaos Not extensively - bit outside what I'm doing - but I've given it a quick curiosity look and it seems to be working as expected. Given the givens, I doubt it's perfect, but it might help the next person get a bit further. Thanks for this post, it was really helpful to get me along – halcyonshift Mar 12 '23 at 07:55
  • Thanks very much, will try it out when I next get a chance! – OrderAndChaos Mar 12 '23 at 19:32