JavaFX Spell checker using RichTextFX how to create right click suggestions

787 views Asked by At

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.
1

There are 1 answers

0
trilogy On BEST ANSWER

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