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