Find all matching text and apply styling in Lexical
Asked Answered
L

3

5

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.

Loaf answered 30/9, 2022 at 16:38 Comment(0)
L
4

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;


Loaf answered 3/10, 2022 at 20:20 Comment(0)
W
2

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')
Waters answered 11/3, 2023 at 20:56 Comment(3)
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.Loaf
@Loaf 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 alongWaters
Thanks very much, will try it out when I next get a chance!Loaf
S
0

For anyone having the same problem: Following is a function that will set highlight format on any matching node with the words array that you have. call this inside a useEffect hook. you can provide the words as array of strings. you can change the style for highlighted text with your CSS outside of the javascript.

 const highlightWords = () => {
      editor.update(() => {
        const root = $getRoot()
        const textNodes: TextNode[] = []

        // Traverse all nodes to find text nodes
        root.getChildren().forEach((node) => {
          if ($isTextNode(node)) {
            textNodes.push(node)
          } else if ($isElementNode(node)) {
            node.getChildren().forEach((child) => {
              if ($isTextNode(child)) {
                textNodes.push(child)
              }
            })
          }
        })

        // Process each text node
        textNodes.forEach((textNode) => {
          let text = textNode.getTextContent()
          let newNodes: TextNode[] = []
          let lastIndex = 0

          words.forEach((word) => {
            const regex = new RegExp(`\\b${word}\\b`, 'gi')
            let match

            while ((match = regex.exec(text)) !== null) {
              const start = match.index
              const end = start + word.length

              // Add text before the match
              if (start > lastIndex) {
                newNodes.push($createTextNode(text.slice(lastIndex, start)))
              }

              // Add highlighted text
              const highlightedNode = $createTextNode(text.slice(start, end))
              highlightedNode.setFormat('highlight')
              newNodes.push(highlightedNode)

              lastIndex = end
            }
          })

          // Add remaining text
          if (lastIndex < text.length) {
            newNodes.push($createTextNode(text.slice(lastIndex)))
          }

          // Replace the original node with new nodes
          if (newNodes.length > 0) {
            textNode.replace(newNodes[0])
            for (let i = 1; i < newNodes.length; i++) {
              newNodes[i - 1].insertAfter(newNodes[i])
            }
          }
        })
      })
    }

What is does is that checks for all matching nodes and goes inside a loop one by one of the nodes and checks the text of the node with the word you are looking for.

Silurian answered 14/10 at 11:34 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.