If you're using EditLive! to edit HTML emails, it's common for users to simple type in a URL like http://www.ephox.com/ directly into the message and expect that it will automatically converted into a hyperlink. Out of the box EditLive! doesn't do this as it's generally better to use descriptive link text and only specify the URL in the actual link tag. Fortunately, it's fairly simple to add this functionality to EditLive! using a plugin and it turns out to be a good example of how to monitor and react to the user's actions in the editor. If you'd prefer to skip the gory details and just grab the working plugin, it's available in ready-to-go form. We've previously looked at how to initialize a plugin using background loading so we'll start from there and build the actual linking functionality.
So to get started, in the last article we created the class below that handles the initialization.
public class Autolink implements EventListener {
private ELJBean _bean;
public Autolink(ELJBean bean) {
_bean = bean;
_bean.addEditorEventListener(this);
if (_bean.isInitFinished()) {
initializePlugin();
}
}
public void raiseEvent(TextEvent e) {
if (e.getActionCommand() == TextEvent.LOADING_COMPLETE) {
initializePlugin();
}
}
}
Now we need to implement the actual initializePlugin method. We'll keep things modular by creating a second class to handle converting links and just instantiate it in initializePlugin():
private void initializePlugin() {
new LinkConverter(_bean.getDocumentModifier(), _bean.getHTMLPane());
}
The LinkConverter works by listening for key strokes in the HTML pane and when the user types a character that could signify the end of a hyperlink (typically a space, enter or a bracket), it looks at the text just before the caret position and if it is a valid URL, uses the document modifier to apply a hyperlink to it. It's a very simple technique that uses a number of heuristics to make the user experience as pleasant as possible.
Firstly, we need to do some house keeping and register as a key listener:
public class LinkConverter extends KeyAdapter {
private final JTextPane _pane;
private final DocumentModifier _modifier;
public LinkConverter(DocumentModifier modifier, JTextPane pane) {
_modifier = modifier;
_pane = pane;
_pane.addKeyListener(this);
}
Next we want to implement keyTyped and look for characters the user has typed that might indicate the end of a hyperlink. We use the heuristic that space, enter or a closing bracket might indicate the end of a hyperlink, this should cover the vast majority of cases even if it's not entirely comprehensive.
public void keyTyped(KeyEvent e) {
if (isTriggerCharacter(e.getKeyChar())) {
checkForUrlsToConvert();
}
}
private boolean isTriggerCharacter(char keyChar) {
return keyChar == ' ' || keyChar == '\n' || keyChar == '\r' || keyChar == ')';
}
The checkForUrlsToConvert method delegates most of the actual work but provides the structure for the algorithm, specifically:
-
Get the text before the current caret position that could be a URL.
-
Determine if the text is a valid URL.
-
If it is, apply a hyperlink to it.
private void checkForUrlsToConvert() {
try {
String link = getMostLikelyLinkText();
if (isUrl(link)) {
int endOffset = _pane.getCaretPosition();
applyHyperlink(endOffset - link.length(), endOffset, link);
}
} catch (Exception ex) {
System.err.println("Failed to create URL from text.");
ex.printStackTrace();
}
}
Getting the text applies two more heuristics:
-
Links don't cross paragraph boundaries so we only need to search from the start of the current paragraph.
-
Valid links don't contain spaces or opening brackets so we only want the text after the last space or bracket.
The opening bracket may seem like an odd heuristic to apply, but it handles the case where the hyperlink is placed in brackets, eg: (http://www.ephox.com/). That's also why the closing bracket is included as a trigger character.
private String getMostLikelyLinkText() throws BadLocationException {
int caretPos = _pane.getCaretPosition();
HTMLDocument document = (HTMLDocument)_pane.getDocument();
// Get the text from the start of the paragraph to the caret position.
// Note that document.getText takes an offset and a length, not an end offset.
Element paragraph = document.getParagraphElement(caretPos);
String link = document.getText(paragraph.getStartOffset(), caretPos - paragraph.getStartOffset());
// This is the second heuristic - the last space or bracket
// indicates the start of the hyperlink.
int startLink = Math.max(link.lastIndexOf(' '), link.lastIndexOf('('));
if (startLink >= 0) {
link = link.substring(startLink + 1);
}
return link;
}
Now that we've got the text of the most likely URL candidate, we need to check if it really is a URL, we do this with two steps:
-
Does it start with a valid protocol?
-
Can we parse it as a URI?
private boolean isUrl(String link) {
if (link.startsWith("http://") || link.startsWith("ftp://") || link.startsWith("https://")) {
try {
new URI(link);
return true;
} catch (URISyntaxException e) {}
}
return false;
}
Finally, we need to actually apply the hyperlink. Most of the details of this are taken care of by the DocumentModifier class. In fact, by using the DocumentModifier the change will be tracked correctly by track changes, undo and redo will work and any formatting that has been applied to the text will be preserved. What we have to do is describe what attributes we want to add or remove and what range to add them in. The range is simple, and we don't need to remove any attributes, so let's look at what attributes we need to add.
The attributes to add are specified as an AttributeSet so we first create one of those. Since we want to add a hyperlink which is an inline tag, we need to add a HTML.Tag.A attribute. The value is another attribute set with the attributes of the link tag we're adding. Fortunately, it's harder to explain than it is to code:
private SimpleAttributeSet getAttributesToAdd(String href) {
SimpleAttributeSet add = new SimpleAttributeSet();
SimpleAttributeSet linkAttributes = new SimpleAttributeSet();
add.addAttribute(HTML.Tag.A, linkAttributes);
// We can add any attributes we need to the link by adding them
// to the linkAttributes attributes set.
linkAttributes.addAttribute(HTML.Attribute.HREF, href);
return add;
}
Note that we use the constants HTML.Tag.A and HTML.Attribute.HREF instead of the strings "a" and "href", the constants are actually not strings and it won't work like you expect if you use strings.
Now that we've got all the components we need, applying the hyperlink is a simple matter of calling DocumentModifier.adjustCharacterAttributes:
private void applyHyperlink(int startOffset, int endOffset, String href)
throws BadLocationException {
_modifier.adjustCharacterAttributes(startOffset, endOffset,
getAttributesToAdd(href), Collections.EMPTY_LIST);
}
When you put all of that together, you have surprisingly effective automatic linking functionality that covers most of the potential use cases. There are obviously a few gaps, the most obvious of which are links in quotes and email addresses, but they use the same fundamental techniques. The final plugin is available for download as a complete, ready to go package with full source code and build scripts - in the future we'll look to build on it to support more use cases and make it more robust.
Finally, let's take a look at the key techniques that we used:
-
Safe initializing of plugins that load in the background.
-
Adding a KeyListener to the HTMLPane to monitor and react to user actions. Obviously any other type of swing listeners could be used when required.
-
Retrieving blocks of plain text content from the document with the getText() method. It's much faster and usually easier to retrieve a block of content from the document and work with it than to iterate over a range one character at a time.
-
Using the DocumentModifier to make changes to the document in a way that works with undo/redo, track changes and handles all the other little details that user's expect like preserving content formatting correctly.
-
Using nested attribute sets and the constants in javax.swing.text.html.HTML.Tag and javax.swing.text.html.HTML.Attribute to define inline tags and attributes.