JavaFX Spell checker using RichTextFX how to create right click suggestions

I found out how. By using the caret position, I can select a word and replace it. The problem is, right clicking didn’t move the caret. So in order to move the caret, you add a listener.

textArea.setOnMouseClicked((MouseEvent mouseEvent) ->
{
    if (mouseEvent.getButton().equals(MouseButton.SECONDARY))
    {
        if (mouseEvent.getClickCount() == 1)
        {
            CharacterHit hit = textArea.hit(mouseEvent.getX(), mouseEvent.getY());
            int characterPosition = hit.getInsertionIndex();

            // move the caret to that character's position
            textArea.moveTo(characterPosition, SelectionPolicy.CLEAR);
        }
    }
});

Edit 1:

Added indexing and concurrency for performance purposes. Context menu is now instantaneous.

Edit 2:

Fixed macOS issue with context menu

enter image description here

Full code:

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.text.BreakIterator;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import javafx.animation.PauseTransition;

import org.fxmisc.flowless.VirtualizedScrollPane;
import org.fxmisc.richtext.StyleClassedTextArea;
import org.fxmisc.richtext.model.StyleSpans;
import org.fxmisc.richtext.model.StyleSpansBuilder;

import javafx.application.Application;
import javafx.concurrent.Task;
import javafx.event.ActionEvent;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.SeparatorMenuItem;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Text;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.apache.commons.lang3.CharUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.apache.commons.text.WordUtils;
import org.apache.commons.text.similarity.JaroWinklerSimilarity;
import org.fxmisc.richtext.CharacterHit;
import org.fxmisc.richtext.NavigationActions.SelectionPolicy;

public class SpellCheckingDemo extends Application
{

    private static final int NUMBER_OF_SUGGESTIONS = 5;
    private static final Set<String> DICTIONARY = ConcurrentHashMap.newKeySet();
    private static final Map<String, List<String>> SUGGESTIONS = new ConcurrentHashMap<>();

    public static void main(String[] args)
    {
        launch(args);

    }

    @Override
    public void start(Stage primaryStage)
    {
        StyleClassedTextArea textArea = new StyleClassedTextArea();
        textArea.setWrapText(true);
        textArea.requestFollowCaret();
        //wait a bit before typing has stopped to compute the highlighting
        PauseTransition textAreaDelay = new PauseTransition(Duration.millis(250));

        textArea.textProperty().addListener((observable, oldValue, newValue) ->
        {
            textAreaDelay.setOnFinished(event ->
            {
                textArea.setStyleSpans(0, computeHighlighting(textArea.getText()));

                //have a new thread index all incorrect words, and pre-populate suggestions
                Task task = new Task<Void>()
                {

                    @Override
                    public Void call()
                    {
                        //iterating over entire list is ok because after the first time, it will hit the index anyway
                        for (String word : SpellCheckingDemo.SUGGESTIONS.keySet())
                        {
                            SpellCheckingDemo.getClosestWords(word);
                            SpellCheckingDemo.getClosestWords(StringUtils.trim(word));

                        }

                        return null;
                    }
                };
                new Thread(task).start();
            });
            textAreaDelay.playFromStart();
        });

        textArea.setOnMouseClicked((MouseEvent mouseEvent) ->
        {
            if (mouseEvent.getButton().equals(MouseButton.SECONDARY))
            {
                if (mouseEvent.getClickCount() == 1)
                {
                    CharacterHit hit = textArea.hit(mouseEvent.getX(), mouseEvent.getY());
                    int characterPosition = hit.getInsertionIndex();

                    // move the caret to that character's position
                    if (StringUtils.isEmpty(textArea.getSelectedText()))
                    {
                        textArea.moveTo(characterPosition, SelectionPolicy.CLEAR);

                    }

                    if (mouseEvent.getTarget() instanceof Text && StringUtils.isEmpty(textArea.getSelectedText()))
                    {

                        textArea.selectWord();

                        //When selecting right next to puncuation and spaces, the replacements elimantes these values. This avoids the issue by moving the caret towards the middle
                        if (!StringUtils.isEmpty(textArea.getSelectedText()) && !CharUtils.isAsciiAlphanumeric(textArea.getSelectedText().charAt(textArea.getSelectedText().length() - 1)))
                        {
                            textArea.moveTo(textArea.getCaretPosition() - 2);
                            textArea.selectWord();

                        }

                        String referenceWord = textArea.getSelectedText();

                        textArea.deselect();

                        if (!NumberUtils.isParsable(referenceWord) && !DICTIONARY.contains(StringUtils.trim(StringUtils.lowerCase(referenceWord))))
                        {
                            ContextMenu context = new ContextMenu();

                            for (String word : SpellCheckingDemo.getClosestWords(referenceWord))
                            {

                                MenuItem item = new MenuItem(word);
                                item.setOnAction((ActionEvent a) ->
                                {

                                    textArea.selectWord();
                                    textArea.replaceSelection(word);
                                    textArea.deselect();

                                });
                                context.getItems().add(item);

                            }

                            if (!context.getItems().isEmpty())
                            {
                                textArea.moveTo(textArea.getCaretPosition() - 1);

                                context.show((Node) mouseEvent.getTarget(), mouseEvent.getScreenX(), mouseEvent.getScreenY());
                                ((Node) mouseEvent.getTarget()).addEventHandler(MouseEvent.MOUSE_PRESSED, event -> context.hide());

                            } else
                            {
                                ContextMenu copyPasteMenu = getCopyPasteMenu(textArea);
                                copyPasteMenu.show((Node) mouseEvent.getTarget(), mouseEvent.getScreenX(), mouseEvent.getScreenY());
                                ((Node) mouseEvent.getTarget()).addEventHandler(MouseEvent.MOUSE_PRESSED, event -> copyPasteMenu.hide());

                            }
                        } else
                        {
                            ContextMenu copyPasteMenu = getCopyPasteMenu(textArea);
                            copyPasteMenu.show((Node) mouseEvent.getTarget(), mouseEvent.getScreenX(), mouseEvent.getScreenY());
                            ((Node) mouseEvent.getTarget()).addEventHandler(MouseEvent.MOUSE_PRESSED, event -> copyPasteMenu.hide());

                        }

                    } else
                    {
                        ContextMenu copyPasteMenu = getCopyPasteMenu(textArea);
                        copyPasteMenu.show((Node) mouseEvent.getTarget(), mouseEvent.getScreenX(), mouseEvent.getScreenY());
                        ((Node) mouseEvent.getTarget()).addEventHandler(MouseEvent.MOUSE_PRESSED, event -> copyPasteMenu.hide());

                    }
                }
            }
        });

        // load the dictionary
        try (InputStream input = SpellCheckingDemo.class.getResourceAsStream("/spellchecking.dict");
                BufferedReader br = new BufferedReader(new InputStreamReader(input)))
        {
            String line;
            while ((line = br.readLine()) != null)
            {
                DICTIONARY.add(line);
            }
        } catch (IOException ex)
        {
            Logger.getLogger(SpellCheckingDemo.class.getName()).log(Level.SEVERE, null, ex);
        }

        // load the sample document
        InputStream input2 = SpellCheckingDemo.class.getResourceAsStream("/spellchecking.txt");
        try (java.util.Scanner s = new java.util.Scanner(input2))
        {
            String document = s.useDelimiter("\\A").hasNext() ? s.next() : "";
            textArea.replaceText(0, 0, document);
        }

        Scene scene = new Scene(new StackPane(new VirtualizedScrollPane<>(textArea)), 600, 400);
        scene.getStylesheets().add(SpellCheckingDemo.class.getResource("/spellchecking.css").toExternalForm());
        primaryStage.setScene(scene);
        primaryStage.setTitle("Spell Checking Demo");
        primaryStage.show();
    }

    private static StyleSpans<Collection<String>> computeHighlighting(String text)
    {

        StyleSpansBuilder<Collection<String>> spansBuilder = new StyleSpansBuilder<>();

        BreakIterator wb = BreakIterator.getWordInstance();
        wb.setText(text);

        int lastIndex = wb.first();
        int lastKwEnd = 0;
        while (lastIndex != BreakIterator.DONE)
        {
            int firstIndex = lastIndex;
            lastIndex = wb.next();

            if (lastIndex != BreakIterator.DONE && Character.isLetterOrDigit(text.charAt(firstIndex)))
            {
                String word = text.substring(firstIndex, lastIndex);

                if (!NumberUtils.isParsable(word) && !DICTIONARY.contains(StringUtils.lowerCase(word)))
                {
                    spansBuilder.add(Collections.emptyList(), firstIndex - lastKwEnd);
                    spansBuilder.add(Collections.singleton("underlined"), lastIndex - firstIndex);
                    lastKwEnd = lastIndex;
                    SpellCheckingDemo.SUGGESTIONS.putIfAbsent(word, Collections.emptyList());
                }
                //System.err.println();
            }
        }
        spansBuilder.add(Collections.emptyList(), text.length() - lastKwEnd);

        return spansBuilder.create();
    }

    public static List<String> getClosestWords(String word)
    {

        //check to see if an suggestions for this word have already been indexed
        if (SpellCheckingDemo.SUGGESTIONS.containsKey(word) && !SpellCheckingDemo.SUGGESTIONS.get(word).isEmpty())
        {
            return SpellCheckingDemo.SUGGESTIONS.get(word);
        }

        List<StringDistancePair> allWordDistances = new ArrayList<>(DICTIONARY.size());

        String lowerCaseWord = StringUtils.lowerCase(word);
        JaroWinklerSimilarity jaroWinklerAlgorithm = new JaroWinklerSimilarity();
        for (String checkWord : DICTIONARY)
        {
            allWordDistances.add(new StringDistancePair(jaroWinklerAlgorithm.apply(lowerCaseWord, checkWord), checkWord));

        }

        allWordDistances.sort(Comparator.comparingDouble(StringDistancePair::getDistance));

        List<String> closestWords = new ArrayList<>(NUMBER_OF_SUGGESTIONS);

        System.out.println(word);
        for (StringDistancePair pair : allWordDistances.subList(allWordDistances.size() - NUMBER_OF_SUGGESTIONS, allWordDistances.size()))
        {
            // 0 is not a match at all, so no point adding to list
            if (pair.getDistance() == 0.0)
            {
                continue;
            }
            String addWord;
            if (StringUtils.isAllUpperCase(word))
            {
                addWord = StringUtils.upperCase(pair.getWord());
            } else if (CharUtils.isAsciiAlphaUpper(word.charAt(0)))
            {
                addWord = WordUtils.capitalize(pair.getWord());
            } else
            {
                addWord = StringUtils.lowerCase(pair.getWord());
            }
            System.out.println(pair);
            closestWords.add(addWord);
        }
        System.out.println();
        Collections.reverse(closestWords);

        //add the suggestion list to index to allow future pulls
        SpellCheckingDemo.SUGGESTIONS.put(word, closestWords);

        return closestWords;

    }

    public static ContextMenu getCopyPasteMenu(StyleClassedTextArea textArea)
    {
        ContextMenu context = new ContextMenu();
        MenuItem cutItem = new MenuItem("Cut");
        cutItem.setOnAction((ActionEvent a) ->
        {

            Clipboard clipboard = Clipboard.getSystemClipboard();
            ClipboardContent content = new ClipboardContent();
            content.putString(textArea.getSelectedText());
            clipboard.setContent(content);
            textArea.replaceSelection("");

        });

        context.getItems().add(cutItem);

        MenuItem copyItem = new MenuItem("Copy");
        copyItem.setOnAction((ActionEvent a) ->
        {

            Clipboard clipboard = Clipboard.getSystemClipboard();
            ClipboardContent content = new ClipboardContent();
            content.putString(textArea.getSelectedText());
            clipboard.setContent(content);

        });

        context.getItems().add(copyItem);

        MenuItem pasteItem = new MenuItem("Paste");
        pasteItem.setOnAction((ActionEvent a) ->
        {

            Clipboard clipboard = Clipboard.getSystemClipboard();
            if (!StringUtils.isEmpty(textArea.getSelectedText()))
            {
                textArea.replaceSelection(clipboard.getString());
            } else
            {
                textArea.insertText(textArea.getCaretPosition(), clipboard.getString());
            }
        });
        context.getItems().add(pasteItem);
        context.getItems().add(new SeparatorMenuItem());

        MenuItem selectAllItem = new MenuItem("Select All");
        selectAllItem.setOnAction((ActionEvent a) ->
        {

            textArea.selectAll();
        });
        context.getItems().add(selectAllItem);

        if (StringUtils.isEmpty(textArea.getSelectedText()))
        {
            cutItem.setDisable(true);
            copyItem.setDisable(true);
        }

        return context;
    }

    private static class StringDistancePair
    {

        private final double x;
        private final String y;

        public StringDistancePair(double x, String y)
        {
            this.x = x;
            this.y = y;
        }

        public String getWord()
        {
            return y;
        }

        public double getDistance()
        {
            return x;
        }

        @Override
        public String toString()
        {
            return StringUtils.join(String.valueOf(getDistance()), " : ", String.valueOf(getWord()));
        }
    }
}

Download the full English dictionary here:
https://github.com/dwyl/english-words/blob/master/words_alpha.txt

Leave a Comment