使用 RichTextFX 的 JavaFX 拼写检查器如何创建右键单击建议

JavaFX Spell checker using RichTextFX how to create right click suggestions

我这里有一个拼写检查器演示,从视觉上看它正是我想要的(红色下划线表示不正确的单词),但我在创建右键单击上下文菜单以应用建议时遇到问题。

我能够在 Text 对象上获得上下文菜单,但我无法使用预测找到要替换的文本在框中的位置。

代码如下:

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();
    }
}

以下文件进入资源文件夹:

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.

我知道怎么做了。通过使用插入符位置,我可以 select 一个词并替换它。问题是,右键单击并没有移动插入符号。所以为了移动插入符,你添加一个监听器。

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);
        }
    }
});

编辑 1:

出于性能目的添加了索引和并发。上下文菜单现在是即时的。

编辑 2:

修复了上下文菜单的 macOS 问题

完整代码:

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.JaroWinklerDistance;
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);
        JaroWinklerDistance jaroWinklerAlgorithm = new JaroWinklerDistance();
        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()));
        }
    }
}

在此处下载完整的英语词典: https://github.com/dwyl/english-words/blob/master/words_alpha.txt