Search code examples
javajavafxrichtextfx

JavaFX Spell checker using RichTextFX how to create right click suggestions


I have a spell checker demo here, visually it is exactly what I want (red underline for words that are not correct), but I'm having trouble creating a right-click context menu to apply suggestions.

I was able to get a context menu on the Text object, but I was not able to find the position of the text in the box to replace using the prediction.

enter image description here

Here is the code:

pom.xml

    <dependency>
        <groupId>org.fxmisc.richtext</groupId>
        <artifactId>richtextfx</artifactId>
        <version>0.10.6</version>
    </dependency>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-text</artifactId>
        <version>1.9</version>
        <type>jar</type>
    </dependency>

SpellCheckDemo.java

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.text.BreakIterator;
import java.time.Duration;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;

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.scene.Scene;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.input.ContextMenuEvent;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Text;
import javafx.stage.Stage;
import org.apache.commons.text.similarity.JaroWinklerDistance;
import org.reactfx.Subscription;

public class SpellCheckingDemo extends Application
{

    private static final Set<String> dictionary = new HashSet<String>();
    private final static double JAROWINKLERDISTANCE_THRESHOLD = .80;

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

    @Override
    public void start(Stage primaryStage)
    {
        StyleClassedTextArea textArea = new StyleClassedTextArea();
        textArea.setWrapText(true);

        Subscription cleanupWhenFinished = textArea.multiPlainChanges()
                .successionEnds(Duration.ofMillis(500))
                .subscribe(change ->
                {
                    textArea.setStyleSpans(0, computeHighlighting(textArea.getText()));
                });
        // call when no longer need it: `cleanupWhenFinished.unsubscribe();`

        textArea.setOnContextMenuRequested((ContextMenuEvent event) ->
        {
            if (event.getTarget() instanceof Text)
            {
                Text text = (Text) event.getTarget();
                ContextMenu context = new ContextMenu();
                JaroWinklerDistance distance = new JaroWinklerDistance();
                for (String word : dictionary)
                {
                    if (distance.apply(text.getText(), word) >= JAROWINKLERDISTANCE_THRESHOLD)
                    {
                        MenuItem item = new MenuItem(word);
                        item.setOnAction(a ->
                        {
                            // how do I find the position of the Text object ?                    
                            textArea.replaceText(25, 25 + text.getText().length(), word);
                        });
                        context.getItems().add(item);

                    }

                }

                context.show(primaryStage, event.getScreenX(), event.getScreenY());

            }
        });

        // 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 e)
        {
            e.printStackTrace();
        }

        // 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).toLowerCase();
                if (!dictionary.contains(word))
                {
                    spansBuilder.add(Collections.emptyList(), firstIndex - lastKwEnd);
                    spansBuilder.add(Collections.singleton("underlined"), lastIndex - firstIndex);
                    lastKwEnd = lastIndex;
                }
                System.err.println();
            }
        }
        spansBuilder.add(Collections.emptyList(), text.length() - lastKwEnd);

        return spansBuilder.create();
    }
}

The following files go into the resource folder:

spellchecking.css

.underlined {
    -rtfx-background-color: #f0f0f0;
    -rtfx-underline-color: red;
    -rtfx-underline-dash-array: 2 2;
    -rtfx-underline-width: 1;
    -rtfx-underline-cap: butt;
}

spellchecking.dict

a
applied
basic
brown
but
could
document
dog
fox
here
if
is
its
jumps
lazy
no
over
quick
rendering
sample
see
styling
the
there
this
were
you

spellchecking.txt

The quik brown fox jumps over the lazy dog.
Ths is a sample dokument.
There is no styling aplied, but if there were, you could see its basic rndering here.

Solution

  • 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