org.jdesktop.swingx.JXEditorPane Maven / Gradle / Ivy
/*
* $Id: JXEditorPane.java 4147 2012-02-01 17:13:24Z kschaefe $
*
* Copyright 2004 Sun Microsystems, Inc., 4150 Network Circle,
* Santa Clara, California 95054, U.S.A. All rights reserved.
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
*/
package org.jdesktop.swingx;
import java.awt.Component;
import java.awt.Rectangle;
import java.awt.datatransfer.Clipboard;
import java.awt.datatransfer.DataFlavor;
import java.awt.datatransfer.Transferable;
import java.awt.event.ActionEvent;
import java.awt.event.ItemEvent;
import java.awt.event.ItemListener;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;
import java.util.Vector;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.MatchResult;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.swing.ActionMap;
import javax.swing.DefaultComboBoxModel;
import javax.swing.DefaultListCellRenderer;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JEditorPane;
import javax.swing.JList;
import javax.swing.KeyStroke;
import javax.swing.SwingConstants;
import javax.swing.SwingUtilities;
import javax.swing.event.CaretEvent;
import javax.swing.event.CaretListener;
import javax.swing.event.UndoableEditEvent;
import javax.swing.event.UndoableEditListener;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.Document;
import javax.swing.text.EditorKit;
import javax.swing.text.Element;
import javax.swing.text.MutableAttributeSet;
import javax.swing.text.Segment;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyledDocument;
import javax.swing.text.StyledEditorKit;
import javax.swing.text.html.HTML;
import javax.swing.text.html.HTMLDocument;
import javax.swing.text.html.HTMLEditorKit;
import javax.swing.undo.CannotRedoException;
import javax.swing.undo.CannotUndoException;
import javax.swing.undo.UndoManager;
import org.jdesktop.beans.JavaBean;
import org.jdesktop.swingx.action.ActionManager;
import org.jdesktop.swingx.action.Targetable;
import org.jdesktop.swingx.action.TargetableSupport;
import org.jdesktop.swingx.plaf.UIAction;
import org.jdesktop.swingx.search.SearchFactory;
import org.jdesktop.swingx.search.Searchable;
/**
*
* {@code JXEditorPane} offers enhanced functionality over the standard {@code
* JEditorPane}. Unlike its parent, {@code JXEdtiorPane} {@link
* JEditorPane#HONOR_DISPLAY_PROPERTIES honors display properties} by default.
* Users can revert to the behavior of {@code JEditorPane} by setting the
* property to {@code false}.
*
* Additional Features
*
* -
* Improved text editing
* -
* The standard text component commands for cut, copy, and
* paste used enhanced selection methods. The commands will only be
* active if there is text to cut or copy selected or valid text in the
* clipboard to paste.
* -
* Improved HTML editing
* -
* Using the context-sensitive approach for the standard text commands, {@code
* JXEditorPane} provides HTML editing commands that alter functionality
* depending on the document state. Currently, the user can quick-format the
* document with headers (H# tags), paragraphs, and breaks.
* -
* Built-in UndoManager
* -
* Text components provide {@link UndoableEditEvent}s. {@code JXEditorPane}
* places those events in an {@link UndoManager} and provides
* undo/redo commands. Undo and redo are context-sensitive (like
* the text commands) and will only be active if it is possible to perform the
* command.
* -
* Built-in search
* -
* Using SwingX {@linkplain SearchFactory search mechanisms}, {@code
* JXEditorPane} provides search capabilities, allowing the user to find text
* within the document.
*
* Example
*
* Creating a {@code JXEditorPane} is no different than creating a {@code
* JEditorPane}. However, the following example demonstrates the best way to
* access the improved command functionality.
*
*
* JXEditorPane editorPane = new JXEditorPane("some URL");
* add(editorPane);
* JToolBar toolBar = ActionContainerFactory.createToolBar(editorPane.getCommands[]);
* toolBar.addSeparator();
* toolBar.add(editorPane.getParagraphSelector());
* setToolBar(toolBar);
*
*
*
* @author Mark Davidson
*/
@JavaBean
public class JXEditorPane extends JEditorPane implements /*Searchable, */Targetable {
private static final Logger LOG = Logger.getLogger(JXEditorPane.class
.getName());
private UndoableEditListener undoHandler;
private UndoManager undoManager;
private CaretListener caretHandler;
private JComboBox selector;
// The ids of supported actions. Perhaps this should be public.
private final static String ACTION_FIND = "find";
private final static String ACTION_UNDO = "undo";
private final static String ACTION_REDO = "redo";
/*
* These next 3 actions are part of a *HACK* to get cut/copy/paste
* support working in the same way as find, undo and redo. in JTextComponent
* the cut/copy/paste actions are _not_ added to the ActionMap. Instead,
* a default "transfer handler" system is used, apparently to get the text
* onto the system clipboard.
* Since there aren't any CUT/COPY/PASTE actions in the JTextComponent's action
* map, they cannot be referenced by the action framework the same way that
* find/undo/redo are. So, I added the actions here. The really hacky part
* is that by defining an Action to go along with the cut/copy/paste keys,
* I loose the default handling in the cut/copy/paste routines. So, I have
* to remove cut/copy/paste from the action map, call the appropriate
* method (cut, copy, or paste) and then add the action back into the
* map. Yuck!
*/
private final static String ACTION_CUT = "cut";
private final static String ACTION_COPY = "copy";
private final static String ACTION_PASTE = "paste";
private TargetableSupport targetSupport = new TargetableSupport(this);
private Searchable searchable;
/**
* Creates a new JXEditorPane
.
* The document model is set to null
.
*/
public JXEditorPane() {
init();
}
/**
* Creates a JXEditorPane
based on a string containing
* a URL specification.
*
* @param url the URL
* @exception IOException if the URL is null
or
* cannot be accessed
*/
public JXEditorPane(String url) throws IOException {
super(url);
init();
}
/**
* Creates a JXEditorPane
that has been initialized
* to the given text. This is a convenience constructor that calls the
* setContentType
and setText
methods.
*
* @param type mime type of the given text
* @param text the text to initialize with; may be null
* @exception NullPointerException if the type
parameter
* is null
*/
public JXEditorPane(String type, String text) {
super(type, text);
init();
}
/**
* Creates a JXEditorPane
based on a specified URL for input.
*
* @param initialPage the URL
* @exception IOException if the URL is null
* or cannot be accessed
*/
public JXEditorPane(URL initialPage) throws IOException {
super(initialPage);
init();
}
private void init() {
putClientProperty(HONOR_DISPLAY_PROPERTIES, true);
setEditorKitForContentType("text/html", new SloppyHTMLEditorKit());
addPropertyChangeListener(new PropertyHandler());
getDocument().addUndoableEditListener(getUndoableEditListener());
initActions();
}
private class PropertyHandler implements PropertyChangeListener {
@Override
public void propertyChange(PropertyChangeEvent evt) {
String name = evt.getPropertyName();
if (name.equals("document")) {
Document doc = (Document)evt.getOldValue();
if (doc != null) {
doc.removeUndoableEditListener(getUndoableEditListener());
}
doc = (Document)evt.getNewValue();
if (doc != null) {
doc.addUndoableEditListener(getUndoableEditListener());
}
}
}
}
// pp for testing
CaretListener getCaretListener() {
return caretHandler;
}
// pp for testing
UndoableEditListener getUndoableEditListener() {
if (undoHandler == null) {
undoHandler = new UndoHandler();
undoManager = new UndoManager();
}
return undoHandler;
}
/**
* Overidden to perform document initialization based on type.
*/
@Override
public void setEditorKit(EditorKit kit) {
super.setEditorKit(kit);
if (kit instanceof StyledEditorKit) {
if (caretHandler == null) {
caretHandler = new CaretHandler();
}
addCaretListener(caretHandler);
}
}
/**
* Register the actions that this class can handle.
*/
protected void initActions() {
ActionMap map = getActionMap();
map.put(ACTION_FIND, new Actions(ACTION_FIND));
map.put(ACTION_UNDO, new Actions(ACTION_UNDO));
map.put(ACTION_REDO, new Actions(ACTION_REDO));
map.put(ACTION_CUT, new Actions(ACTION_CUT));
map.put(ACTION_COPY, new Actions(ACTION_COPY));
map.put(ACTION_PASTE, new Actions(ACTION_PASTE));
KeyStroke findStroke = SearchFactory.getInstance().getSearchAccelerator();
getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).put(findStroke, "find");
}
// undo/redo implementation
private class UndoHandler implements UndoableEditListener {
@Override
public void undoableEditHappened(UndoableEditEvent evt) {
undoManager.addEdit(evt.getEdit());
updateActionState();
}
}
/**
* Updates the state of the actions in response to an undo/redo operation.
*
*/
private void updateActionState() {
// Update the state of the undo and redo actions
// JW: fiddling with actionManager's actions state? I'm pretty sure
// we don't want that: the manager will get nuts with multiple
// components with different state.
// It's up to whatever manager to listen
// to our changes and update itself accordingly. Which is not
// well supported with the current design ... nobody
// really cares about enabled as it should.
//
Runnable doEnabled = new Runnable() {
@Override
public void run() {
ActionManager manager = ActionManager.getInstance();
manager.setEnabled(ACTION_UNDO, undoManager.canUndo());
manager.setEnabled(ACTION_REDO, undoManager.canRedo());
}
};
SwingUtilities.invokeLater(doEnabled);
}
/**
* A small class which dispatches actions.
* TODO: Is there a way that we can make this static?
* JW: these if-constructs are totally crazy ... we live in OO world!
*
*/
private class Actions extends UIAction {
Actions(String name) {
super(name);
}
@Override
public void actionPerformed(ActionEvent evt) {
String name = getName();
if (ACTION_FIND.equals(name)) {
find();
}
else if (ACTION_UNDO.equals(name)) {
try {
undoManager.undo();
} catch (CannotUndoException ex) {
LOG.info("Could not undo");
}
updateActionState();
}
else if (ACTION_REDO.equals(name)) {
try {
undoManager.redo();
} catch (CannotRedoException ex) {
LOG.info("Could not redo");
}
updateActionState();
} else if (ACTION_CUT.equals(name)) {
ActionMap map = getActionMap();
map.remove(ACTION_CUT);
cut();
map.put(ACTION_CUT, this);
} else if (ACTION_COPY.equals(name)) {
ActionMap map = getActionMap();
map.remove(ACTION_COPY);
copy();
map.put(ACTION_COPY, this);
} else if (ACTION_PASTE.equals(name)) {
ActionMap map = getActionMap();
map.remove(ACTION_PASTE);
paste();
map.put(ACTION_PASTE, this);
}
else {
LOG.fine("ActionHandled: " + name);
}
}
@Override
public boolean isEnabled(Object sender) {
String name = getName();
if (ACTION_UNDO.equals(name)) {
return isEditable() && undoManager.canUndo();
}
if (ACTION_REDO.equals(name)) {
return isEditable() && undoManager.canRedo();
}
if (ACTION_PASTE.equals(name)) {
if (!isEditable()) return false;
// is this always possible?
boolean dataOnClipboard = false;
try {
dataOnClipboard = getToolkit()
.getSystemClipboard().getContents(null) != null;
} catch (Exception e) {
// can't do anything - clipboard unaccessible
}
return dataOnClipboard;
}
boolean selectedText = getSelectionEnd()
- getSelectionStart() > 0;
if (ACTION_CUT.equals(name)) {
return isEditable() && selectedText;
}
if (ACTION_COPY.equals(name)) {
return selectedText;
}
if (ACTION_FIND.equals(name)) {
return getDocument().getLength() > 0;
}
return true;
}
}
/**
* Retrieves a component which will be used as the paragraph selector.
* This can be placed in the toolbar.
*
* Note: This is only valid for the HTMLEditorKit
*/
public JComboBox getParagraphSelector() {
if (selector == null) {
selector = new ParagraphSelector();
}
return selector;
}
/**
* A control which should be placed in the toolbar to enable
* paragraph selection.
*/
private class ParagraphSelector extends JComboBox implements ItemListener {
private Map itemMap;
public ParagraphSelector() {
// The item map is for rendering
itemMap = new HashMap();
itemMap.put(HTML.Tag.P, "Paragraph");
itemMap.put(HTML.Tag.H1, "Heading 1");
itemMap.put(HTML.Tag.H2, "Heading 2");
itemMap.put(HTML.Tag.H3, "Heading 3");
itemMap.put(HTML.Tag.H4, "Heading 4");
itemMap.put(HTML.Tag.H5, "Heading 5");
itemMap.put(HTML.Tag.H6, "Heading 6");
itemMap.put(HTML.Tag.PRE, "Preformatted");
// The list of items
Vector items = new Vector();
items.addElement(HTML.Tag.P);
items.addElement(HTML.Tag.H1);
items.addElement(HTML.Tag.H2);
items.addElement(HTML.Tag.H3);
items.addElement(HTML.Tag.H4);
items.addElement(HTML.Tag.H5);
items.addElement(HTML.Tag.H6);
items.addElement(HTML.Tag.PRE);
setModel(new DefaultComboBoxModel(items));
setRenderer(new ParagraphRenderer());
addItemListener(this);
setFocusable(false);
}
@Override
public void itemStateChanged(ItemEvent evt) {
if (evt.getStateChange() == ItemEvent.SELECTED) {
applyTag((HTML.Tag)evt.getItem());
}
}
private class ParagraphRenderer extends DefaultListCellRenderer {
public ParagraphRenderer() {
setOpaque(true);
}
@Override
public Component getListCellRendererComponent(JList list,
Object value,
int index,
boolean isSelected,
boolean cellHasFocus) {
super.getListCellRendererComponent(list, value, index, isSelected,
cellHasFocus);
setText((String)itemMap.get(value));
return this;
}
}
// TODO: Should have a rendererer which does stuff like:
// Paragraph, Heading 1, etc...
}
/**
* Applys the tag to the current selection
*/
protected void applyTag(HTML.Tag tag) {
Document doc = getDocument();
if (!(doc instanceof HTMLDocument)) {
return;
}
HTMLDocument hdoc = (HTMLDocument)doc;
int start = getSelectionStart();
int end = getSelectionEnd();
Element element = hdoc.getParagraphElement(start);
MutableAttributeSet newAttrs = new SimpleAttributeSet(element.getAttributes());
newAttrs.addAttribute(StyleConstants.NameAttribute, tag);
hdoc.setParagraphAttributes(start, end - start, newAttrs, true);
}
/**
* The paste method has been overloaded to strip off the tags
* This doesn't really work.
*/
@Override
public void paste() {
Clipboard clipboard = getToolkit().getSystemClipboard();
Transferable content = clipboard.getContents(this);
if (content != null) {
DataFlavor[] flavors = content.getTransferDataFlavors();
try {
for (int i = 0; i < flavors.length; i++) {
if (String.class.equals(flavors[i].getRepresentationClass())) {
Object data = content.getTransferData(flavors[i]);
if (flavors[i].isMimeTypeEqual("text/plain")) {
// This works but we lose all the formatting.
replaceSelection(data.toString());
break;
}
}
}
} catch (Exception ex) {
// TODO change to something meaningful - when can this acutally happen?
LOG.log(Level.FINE, "What can produce a problem with data flavor?", ex);
}
}
}
private void find() {
SearchFactory.getInstance().showFindInput(this, getSearchable());
}
/**
*
* @return a not-null Searchable for this editor.
*/
public Searchable getSearchable() {
if (searchable == null) {
searchable = new DocumentSearchable();
}
return searchable;
}
/**
* sets the Searchable for this editor. If null, a default
* searchable will be used.
*
* @param searchable
*/
public void setSearchable(Searchable searchable) {
this.searchable = searchable;
}
/**
* A {@code Searchable} implementation for {@code Document}s.
*/
public class DocumentSearchable implements Searchable {
@Override
public int search(String searchString) {
return search(searchString, -1);
}
@Override
public int search(String searchString, int columnIndex) {
return search(searchString, columnIndex, false);
}
@Override
public int search(String searchString, int columnIndex, boolean backward) {
Pattern pattern = null;
if (!isEmpty(searchString)) {
pattern = Pattern.compile(searchString, 0);
}
return search(pattern, columnIndex, backward);
}
/**
* checks if the searchString should be interpreted as empty.
* here: returns true if string is null or has zero length.
*
* TODO: This should be in a utility class.
*
* @param searchString
* @return true if string is null or has zero length
*/
protected boolean isEmpty(String searchString) {
return (searchString == null) || searchString.length() == 0;
}
@Override
public int search(Pattern pattern) {
return search(pattern, -1);
}
@Override
public int search(Pattern pattern, int startIndex) {
return search(pattern, startIndex, false);
}
int lastFoundIndex = -1;
MatchResult lastMatchResult;
String lastRegEx;
/**
* @return start position of matching string or -1
*/
@Override
public int search(Pattern pattern, final int startIndex,
boolean backwards) {
if ((pattern == null)
|| (getDocument().getLength() == 0)
|| ((startIndex > -1) && (getDocument().getLength() < startIndex))) {
updateStateAfterNotFound();
return -1;
}
int start = startIndex;
if (maybeExtendedMatch(startIndex)) {
if (foundExtendedMatch(pattern, start)) {
return lastFoundIndex;
}
start++;
}
int length;
if (backwards) {
start = 0;
if (startIndex < 0) {
length = getDocument().getLength() - 1;
} else {
length = -1 + startIndex;
}
} else {
// start = startIndex + 1;
if (start < 0)
start = 0;
length = getDocument().getLength() - start;
}
Segment segment = new Segment();
try {
getDocument().getText(start, length, segment);
} catch (BadLocationException ex) {
LOG.log(Level.FINE,
"this should not happen (calculated the valid start/length) " , ex);
}
Matcher matcher = pattern.matcher(segment.toString());
MatchResult currentResult = getMatchResult(matcher, !backwards);
if (currentResult != null) {
updateStateAfterFound(currentResult, start);
} else {
updateStateAfterNotFound();
}
return lastFoundIndex;
}
/**
* Search from same startIndex as the previous search.
* Checks if the match is different from the last (either
* extended/reduced) at the same position. Returns true
* if the current match result represents a different match
* than the last, false if no match or the same.
*
* @param pattern
* @param start
* @return true if the current match result represents a different
* match than the last, false if no match or the same.
*/
private boolean foundExtendedMatch(Pattern pattern, int start) {
// JW: logic still needs cleanup...
if (pattern.pattern().equals(lastRegEx)) {
return false;
}
int length = getDocument().getLength() - start;
Segment segment = new Segment();
try {
getDocument().getText(start, length, segment);
} catch (BadLocationException ex) {
LOG.log(Level.FINE,
"this should not happen (calculated the valid start/length) " , ex);
}
Matcher matcher = pattern.matcher(segment.toString());
MatchResult currentResult = getMatchResult(matcher, true);
if (currentResult != null) {
// JW: how to compare match results reliably?
// the group().equals probably isn't the best idea...
// better check pattern?
if ((currentResult.start() == 0) &&
(!lastMatchResult.group().equals(currentResult.group()))) {
updateStateAfterFound(currentResult, start);
return true;
}
}
return false;
}
/**
* Checks if the startIndex is a candidate for trying a re-match.
*
*
* @param startIndex
* @return true if the startIndex should be re-matched, false if not.
*/
private boolean maybeExtendedMatch(final int startIndex) {
return (startIndex >= 0) && (startIndex == lastFoundIndex);
}
/**
* @param currentResult
* @param offset
* @return the start position of the selected text
*/
private int updateStateAfterFound(MatchResult currentResult, final int offset) {
int end = currentResult.end() + offset;
int found = currentResult.start() + offset;
select(found, end);
getCaret().setSelectionVisible(true);
lastFoundIndex = found;
lastMatchResult = currentResult;
lastRegEx = ((Matcher) lastMatchResult).pattern().pattern();
return found;
}
/**
* @param matcher
* @param useFirst whether or not to return after the first match is found.
* @return MatchResult
or null
*/
private MatchResult getMatchResult(Matcher matcher, boolean useFirst) {
MatchResult currentResult = null;
while (matcher.find()) {
currentResult = matcher.toMatchResult();
if (useFirst) break;
}
return currentResult;
}
/**
*/
private void updateStateAfterNotFound() {
lastFoundIndex = -1;
lastMatchResult = null;
lastRegEx = null;
setCaretPosition(getSelectionEnd());
}
}
@Override
public boolean hasCommand(Object command) {
return targetSupport.hasCommand(command);
}
@Override
public Object[] getCommands() {
return targetSupport.getCommands();
}
@Override
public boolean doCommand(Object command, Object value) {
return targetSupport.doCommand(command, value);
}
/**
* {@inheritDoc}
*/
@Override
public int getScrollableUnitIncrement(Rectangle visibleRect, int orientation, int direction) {
switch(orientation) {
case SwingConstants.VERTICAL:
return getFontMetrics(getFont()).getHeight();
case SwingConstants.HORIZONTAL:
return getFontMetrics(getFont()).charWidth('M');
default:
throw new IllegalArgumentException("Invalid orientation: " + orientation);
}
}
/**
* Listens to the caret placement and adjusts the editing
* properties as appropriate.
*
* Should add more attributes as required.
*/
private class CaretHandler implements CaretListener {
@Override
public void caretUpdate(CaretEvent evt) {
StyledDocument document = (StyledDocument)getDocument();
int dot = evt.getDot();
//SwingX #257--ensure display shows the valid attributes
dot = dot > 0 ? dot - 1 : dot;
Element elem = document.getCharacterElement(dot);
AttributeSet set = elem.getAttributes();
// JW: see comment in updateActionState
ActionManager manager = ActionManager.getInstance();
manager.setSelected("font-bold", StyleConstants.isBold(set));
manager.setSelected("font-italic", StyleConstants.isItalic(set));
manager.setSelected("font-underline", StyleConstants.isUnderline(set));
elem = document.getParagraphElement(dot);
set = elem.getAttributes();
// Update the paragraph selector if applicable.
if (selector != null) {
selector.setSelectedItem(set.getAttribute(StyleConstants.NameAttribute));
}
switch (StyleConstants.getAlignment(set)) {
// XXX There is a bug here. the setSelected method
// should only affect the UI actions rather than propagate
// down into the action map actions.
case StyleConstants.ALIGN_LEFT:
manager.setSelected("left-justify", true);
break;
case StyleConstants.ALIGN_CENTER:
manager.setSelected("center-justify", true);
break;
case StyleConstants.ALIGN_RIGHT:
manager.setSelected("right-justify", true);
break;
}
}
}
/**
* Handles sloppy HTML. This implementation currently only looks for
* tags that have a / at the end (self-closing tags) and fixes them
* to work with the version of HTML supported by HTMLEditorKit
* TODO: Need to break this functionality out so it can take pluggable
* replacement code blocks, allowing people to write custom replacement
* routines. The idea is that with some simple modifications a lot more
* sloppy HTML can be rendered correctly.
*
* @author rbair
*/
private static final class SloppyHTMLEditorKit extends HTMLEditorKit {
@Override
public void read(Reader in, Document doc, int pos) throws IOException, BadLocationException {
//read the reader into a String
StringBuffer buffer = new StringBuffer();
int length;
char[] data = new char[1024];
while ((length = in.read(data)) != -1) {
buffer.append(data, 0, length);
}
//TODO is this regex right?
StringReader reader = new StringReader(buffer.toString().replaceAll("/>", ">"));
super.read(reader, doc, pos);
}
}
}