001 /*
002 * $Id: JXEditorPane.java,v 1.19 2006/05/14 08:12:15 dmouse Exp $
003 *
004 * Copyright 2004 Sun Microsystems, Inc., 4150 Network Circle,
005 * Santa Clara, California 95054, U.S.A. All rights reserved.
006 *
007 * This library is free software; you can redistribute it and/or
008 * modify it under the terms of the GNU Lesser General Public
009 * License as published by the Free Software Foundation; either
010 * version 2.1 of the License, or (at your option) any later version.
011 *
012 * This library is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
015 * Lesser General Public License for more details.
016 *
017 * You should have received a copy of the GNU Lesser General Public
018 * License along with this library; if not, write to the Free Software
019 * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
020 */
021
022 package org.jdesktop.swingx;
023
024 import java.awt.Component;
025 import java.awt.datatransfer.Clipboard;
026 import java.awt.datatransfer.DataFlavor;
027 import java.awt.datatransfer.Transferable;
028 import java.awt.event.ActionEvent;
029 import java.awt.event.ItemEvent;
030 import java.awt.event.ItemListener;
031 import java.beans.PropertyChangeEvent;
032 import java.beans.PropertyChangeListener;
033 import java.io.IOException;
034 import java.io.Reader;
035 import java.io.StringReader;
036 import java.net.URL;
037 import java.util.HashMap;
038 import java.util.Map;
039 import java.util.Vector;
040 import java.util.logging.Level;
041 import java.util.logging.Logger;
042 import java.util.regex.MatchResult;
043 import java.util.regex.Matcher;
044 import java.util.regex.Pattern;
045
046 import javax.swing.ActionMap;
047 import javax.swing.DefaultComboBoxModel;
048 import javax.swing.DefaultListCellRenderer;
049 import javax.swing.JComboBox;
050 import javax.swing.JComponent;
051 import javax.swing.JEditorPane;
052 import javax.swing.JList;
053 import javax.swing.KeyStroke;
054 import javax.swing.SwingUtilities;
055 import javax.swing.event.CaretEvent;
056 import javax.swing.event.CaretListener;
057 import javax.swing.event.UndoableEditEvent;
058 import javax.swing.event.UndoableEditListener;
059 import javax.swing.text.AttributeSet;
060 import javax.swing.text.BadLocationException;
061 import javax.swing.text.Document;
062 import javax.swing.text.EditorKit;
063 import javax.swing.text.Element;
064 import javax.swing.text.MutableAttributeSet;
065 import javax.swing.text.Segment;
066 import javax.swing.text.SimpleAttributeSet;
067 import javax.swing.text.StyleConstants;
068 import javax.swing.text.StyledDocument;
069 import javax.swing.text.StyledEditorKit;
070 import javax.swing.text.html.HTML;
071 import javax.swing.text.html.HTMLDocument;
072 import javax.swing.text.html.HTMLEditorKit;
073 import javax.swing.undo.CannotRedoException;
074 import javax.swing.undo.CannotUndoException;
075 import javax.swing.undo.UndoManager;
076
077 import org.jdesktop.swingx.action.ActionManager;
078 import org.jdesktop.swingx.action.Targetable;
079
080
081 /**
082 * An extended editor pane which has the following features built in:
083 * <ul>
084 * <li>Text search
085 * <li>undo/redo
086 * <li>simple html/plain text editing
087 * </ul>
088 *
089 * @author Mark Davidson
090 */
091 public class JXEditorPane extends JEditorPane implements /*Searchable, */Targetable {
092
093 private static final Logger LOG = Logger.getLogger(JXEditorPane.class
094 .getName());
095
096 private UndoableEditListener undoHandler;
097 private UndoManager undoManager;
098 private CaretListener caretHandler;
099 private JComboBox selector;
100
101 // The ids of supported actions. Perhaps this should be public.
102 private final static String ACTION_FIND = "find";
103 private final static String ACTION_UNDO = "undo";
104 private final static String ACTION_REDO = "redo";
105 /*
106 * These next 3 actions are part of a *HACK* to get cut/copy/paste
107 * support working in the same way as find, undo and redo. in JTextComponent
108 * the cut/copy/paste actions are _not_ added to the ActionMap. Instead,
109 * a default "transfer handler" system is used, apparently to get the text
110 * onto the system clipboard.
111 * Since there aren't any CUT/COPY/PASTE actions in the JTextComponent's action
112 * map, they cannot be referenced by the action framework the same way that
113 * find/undo/redo are. So, I added the actions here. The really hacky part
114 * is that by defining an Action to go along with the cut/copy/paste keys,
115 * I loose the default handling in the cut/copy/paste routines. So, I have
116 * to remove cut/copy/paste from the action map, call the appropriate
117 * method (cut, copy, or paste) and then add the action back into the
118 * map. Yuck!
119 */
120 private final static String ACTION_CUT = "cut";
121 private final static String ACTION_COPY = "copy";
122 private final static String ACTION_PASTE = "paste";
123
124 private TargetableSupport targetSupport = new TargetableSupport(this);
125 private Searchable searchable;
126
127 public JXEditorPane() {
128 init();
129 }
130
131 public JXEditorPane(String url) throws IOException {
132 super(url);
133 init();
134 }
135
136 public JXEditorPane(String type, String text) {
137 super(type, text);
138 init();
139 }
140
141 public JXEditorPane(URL initialPage) throws IOException {
142 super(initialPage);
143 init();
144 }
145
146 private void init() {
147 setEditorKitForContentType("text/html", new SloppyHTMLEditorKit());
148 addPropertyChangeListener(new PropertyHandler());
149 getDocument().addUndoableEditListener(getUndoableEditListener());
150 initActions();
151 }
152
153 private class PropertyHandler implements PropertyChangeListener {
154 public void propertyChange(PropertyChangeEvent evt) {
155 String name = evt.getPropertyName();
156 if (name.equals("document")) {
157 Document doc = (Document)evt.getOldValue();
158 if (doc != null) {
159 doc.removeUndoableEditListener(getUndoableEditListener());
160 }
161
162 doc = (Document)evt.getNewValue();
163 if (doc != null) {
164 doc.addUndoableEditListener(getUndoableEditListener());
165 }
166 }
167 }
168
169 }
170
171 // pp for testing
172 CaretListener getCaretListener() {
173 return caretHandler;
174 }
175
176 // pp for testing
177 UndoableEditListener getUndoableEditListener() {
178 if (undoHandler == null) {
179 undoHandler = new UndoHandler();
180 undoManager = new UndoManager();
181 }
182 return undoHandler;
183 }
184
185 /**
186 * Overidden to perform document initialization based on type.
187 */
188 public void setEditorKit(EditorKit kit) {
189 super.setEditorKit(kit);
190
191 if (kit instanceof StyledEditorKit) {
192 if (caretHandler == null) {
193 caretHandler = new CaretHandler();
194 }
195 addCaretListener(caretHandler);
196 }
197 }
198
199 /**
200 * Register the actions that this class can handle.
201 */
202 protected void initActions() {
203 ActionMap map = getActionMap();
204 map.put(ACTION_FIND, new Actions(ACTION_FIND));
205 map.put(ACTION_UNDO, new Actions(ACTION_UNDO));
206 map.put(ACTION_REDO, new Actions(ACTION_REDO));
207 map.put(ACTION_CUT, new Actions(ACTION_CUT));
208 map.put(ACTION_COPY, new Actions(ACTION_COPY));
209 map.put(ACTION_PASTE, new Actions(ACTION_PASTE));
210 // this should be handled by the LF!
211 KeyStroke findStroke = KeyStroke.getKeyStroke("control F");
212 getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).put(findStroke, "find");
213 }
214
215 // undo/redo implementation
216
217 private class UndoHandler implements UndoableEditListener {
218 public void undoableEditHappened(UndoableEditEvent evt) {
219 undoManager.addEdit(evt.getEdit());
220 updateActionState();
221 }
222 }
223
224 /**
225 * Updates the state of the actions in response to an undo/redo operation.
226 */
227 private void updateActionState() {
228 // Update the state of the undo and redo actions
229 Runnable doEnabled = new Runnable() {
230 public void run() {
231 ActionManager manager = ActionManager.getInstance();
232 manager.setEnabled(ACTION_UNDO, undoManager.canUndo());
233 manager.setEnabled(ACTION_REDO, undoManager.canRedo());
234 }
235 };
236 SwingUtilities.invokeLater(doEnabled);
237 }
238
239 /**
240 * A small class which dispatches actions.
241 * TODO: Is there a way that we can make this static?
242 */
243 private class Actions extends UIAction {
244 Actions(String name) {
245 super(name);
246 }
247
248 public void actionPerformed(ActionEvent evt) {
249 String name = getName();
250 if (ACTION_FIND.equals(name)) {
251 find();
252 }
253 else if (ACTION_UNDO.equals(name)) {
254 try {
255 undoManager.undo();
256 } catch (CannotUndoException ex) {
257 LOG.info("Could not undo");
258 }
259 updateActionState();
260 }
261 else if (ACTION_REDO.equals(name)) {
262 try {
263 undoManager.redo();
264 } catch (CannotRedoException ex) {
265 LOG.info("Could not redo");
266 }
267 updateActionState();
268 } else if (ACTION_CUT.equals(name)) {
269 ActionMap map = getActionMap();
270 map.remove(ACTION_CUT);
271 cut();
272 map.put(ACTION_CUT, this);
273 } else if (ACTION_COPY.equals(name)) {
274 ActionMap map = getActionMap();
275 map.remove(ACTION_COPY);
276 copy();
277 map.put(ACTION_COPY, this);
278 } else if (ACTION_PASTE.equals(name)) {
279 ActionMap map = getActionMap();
280 map.remove(ACTION_PASTE);
281 paste();
282 map.put(ACTION_PASTE, this);
283 }
284 else {
285 LOG.fine("ActionHandled: " + name);
286 }
287
288 }
289 }
290
291 /**
292 * Retrieves a component which will be used as the paragraph selector.
293 * This can be placed in the toolbar.
294 * <p>
295 * Note: This is only valid for the HTMLEditorKit
296 */
297 public JComboBox getParagraphSelector() {
298 if (selector == null) {
299 selector = new ParagraphSelector();
300 }
301 return selector;
302 }
303
304 /**
305 * A control which should be placed in the toolbar to enable
306 * paragraph selection.
307 */
308 private class ParagraphSelector extends JComboBox implements ItemListener {
309
310 private Map itemMap;
311
312 public ParagraphSelector() {
313
314 // The item map is for rendering
315 itemMap = new HashMap();
316 itemMap.put(HTML.Tag.P, "Paragraph");
317 itemMap.put(HTML.Tag.H1, "Heading 1");
318 itemMap.put(HTML.Tag.H2, "Heading 2");
319 itemMap.put(HTML.Tag.H3, "Heading 3");
320 itemMap.put(HTML.Tag.H4, "Heading 4");
321 itemMap.put(HTML.Tag.H5, "Heading 5");
322 itemMap.put(HTML.Tag.H6, "Heading 6");
323 itemMap.put(HTML.Tag.PRE, "Preformatted");
324
325 // The list of items
326 Vector items = new Vector();
327 items.addElement(HTML.Tag.P);
328 items.addElement(HTML.Tag.H1);
329 items.addElement(HTML.Tag.H2);
330 items.addElement(HTML.Tag.H3);
331 items.addElement(HTML.Tag.H4);
332 items.addElement(HTML.Tag.H5);
333 items.addElement(HTML.Tag.H6);
334 items.addElement(HTML.Tag.PRE);
335
336 setModel(new DefaultComboBoxModel(items));
337 setRenderer(new ParagraphRenderer());
338 addItemListener(this);
339 setFocusable(false);
340 }
341
342 public void itemStateChanged(ItemEvent evt) {
343 if (evt.getStateChange() == ItemEvent.SELECTED) {
344 applyTag((HTML.Tag)evt.getItem());
345 }
346 }
347
348 private class ParagraphRenderer extends DefaultListCellRenderer {
349
350 public ParagraphRenderer() {
351 setOpaque(true);
352 }
353
354 public Component getListCellRendererComponent(JList list,
355 Object value,
356 int index,
357 boolean isSelected,
358 boolean cellHasFocus) {
359 super.getListCellRendererComponent(list, value, index, isSelected,
360 cellHasFocus);
361
362 setText((String)itemMap.get(value));
363
364 return this;
365 }
366 }
367
368
369 // TODO: Should have a rendererer which does stuff like:
370 // Paragraph, Heading 1, etc...
371 }
372
373 /**
374 * Applys the tag to the current selection
375 */
376 protected void applyTag(HTML.Tag tag) {
377 Document doc = getDocument();
378 if (!(doc instanceof HTMLDocument)) {
379 return;
380 }
381 HTMLDocument hdoc = (HTMLDocument)doc;
382 int start = getSelectionStart();
383 int end = getSelectionEnd();
384
385 Element element = hdoc.getParagraphElement(start);
386 MutableAttributeSet newAttrs = new SimpleAttributeSet(element.getAttributes());
387 newAttrs.addAttribute(StyleConstants.NameAttribute, tag);
388
389 hdoc.setParagraphAttributes(start, end - start, newAttrs, true);
390 }
391
392 /**
393 * The paste method has been overloaded to strip off the <html><body> tags
394 * This doesn't really work.
395 */
396 public void paste() {
397 Clipboard clipboard = getToolkit().getSystemClipboard();
398 Transferable content = clipboard.getContents(this);
399 if (content != null) {
400 DataFlavor[] flavors = content.getTransferDataFlavors();
401 try {
402 for (int i = 0; i < flavors.length; i++) {
403 if (String.class.equals(flavors[i].getRepresentationClass())) {
404 Object data = content.getTransferData(flavors[i]);
405
406 if (flavors[i].isMimeTypeEqual("text/plain")) {
407 // This works but we lose all the formatting.
408 replaceSelection(data.toString());
409 break;
410 }
411 }
412 }
413 } catch (Exception ex) {
414 // TODO change to something meaningful - when can this acutally happen?
415 LOG.log(Level.FINE, "What can produce a problem with data flavor?", ex);
416 }
417 }
418 }
419
420 private void find() {
421 SearchFactory.getInstance().showFindInput(this, getSearchable());
422 }
423
424 /**
425 *
426 * @return a not-null Searchable for this editor.
427 */
428 public Searchable getSearchable() {
429 if (searchable == null) {
430 searchable = new DocumentSearchable();
431 }
432 return searchable;
433 }
434
435 /**
436 * sets the Searchable for this editor. If null, a default
437 * searchable will be used.
438 *
439 * @param searchable
440 */
441 public void setSearchable(Searchable searchable) {
442 this.searchable = searchable;
443 }
444
445 public class DocumentSearchable implements Searchable {
446 public int search(String searchString) {
447 return search(searchString, -1);
448 }
449
450 public int search(String searchString, int columnIndex) {
451 return search(searchString, columnIndex, false);
452 }
453
454 public int search(String searchString, int columnIndex, boolean backward) {
455 Pattern pattern = null;
456 if (!isEmpty(searchString)) {
457 pattern = Pattern.compile(searchString, 0);
458 }
459 return search(pattern, columnIndex, backward);
460 }
461
462 /**
463 * checks if the searchString should be interpreted as empty.
464 * here: returns true if string is null or has zero length.
465 *
466 * TODO: This should be in a utility class.
467 *
468 * @param searchString
469 * @return true if string is null or has zero length
470 */
471 protected boolean isEmpty(String searchString) {
472 return (searchString == null) || searchString.length() == 0;
473 }
474
475 public int search(Pattern pattern) {
476 return search(pattern, -1);
477 }
478
479 public int search(Pattern pattern, int startIndex) {
480 return search(pattern, startIndex, false);
481 }
482
483 int lastFoundIndex = -1;
484
485 MatchResult lastMatchResult;
486 String lastRegEx;
487 /**
488 * @return start position of matching string or -1
489 */
490 public int search(Pattern pattern, final int startIndex,
491 boolean backwards) {
492 if ((pattern == null)
493 || (getDocument().getLength() == 0)
494 || ((startIndex > -1) && (getDocument().getLength() < startIndex))) {
495 updateStateAfterNotFound();
496 return -1;
497 }
498
499 int start = startIndex;
500 if (maybeExtendedMatch(startIndex)) {
501 if (foundExtendedMatch(pattern, start)) {
502 return lastFoundIndex;
503 }
504 start++;
505 }
506
507 int length;
508 if (backwards) {
509 start = 0;
510 if (startIndex < 0) {
511 length = getDocument().getLength() - 1;
512 } else {
513 length = -1 + startIndex;
514 }
515 } else {
516 // start = startIndex + 1;
517 if (start < 0)
518 start = 0;
519 length = getDocument().getLength() - start;
520 }
521 Segment segment = new Segment();
522
523 try {
524 getDocument().getText(start, length, segment);
525 } catch (BadLocationException ex) {
526 LOG.log(Level.FINE,
527 "this should not happen (calculated the valid start/length) " , ex);
528 }
529
530 Matcher matcher = pattern.matcher(segment.toString());
531 MatchResult currentResult = getMatchResult(matcher, !backwards);
532 if (currentResult != null) {
533 updateStateAfterFound(currentResult, start);
534 } else {
535 updateStateAfterNotFound();
536 }
537 return lastFoundIndex;
538
539 }
540
541 /**
542 * Search from same startIndex as the previous search.
543 * Checks if the match is different from the last (either
544 * extended/reduced) at the same position. Returns true
545 * if the current match result represents a different match
546 * than the last, false if no match or the same.
547 *
548 * @param pattern
549 * @param start
550 * @return true if the current match result represents a different
551 * match than the last, false if no match or the same.
552 */
553 private boolean foundExtendedMatch(Pattern pattern, int start) {
554 // JW: logic still needs cleanup...
555 if (pattern.pattern().equals(lastRegEx)) {
556 return false;
557 }
558 int length = getDocument().getLength() - start;
559 Segment segment = new Segment();
560
561 try {
562 getDocument().getText(start, length, segment);
563 } catch (BadLocationException ex) {
564 LOG.log(Level.FINE,
565 "this should not happen (calculated the valid start/length) " , ex);
566 }
567 Matcher matcher = pattern.matcher(segment.toString());
568 MatchResult currentResult = getMatchResult(matcher, true);
569 if (currentResult != null) {
570 // JW: how to compare match results reliably?
571 // the group().equals probably isn't the best idea...
572 // better check pattern?
573 if ((currentResult.start() == 0) &&
574 (!lastMatchResult.group().equals(currentResult.group()))) {
575 updateStateAfterFound(currentResult, start);
576 return true;
577 }
578 }
579 return false;
580 }
581
582 /**
583 * Checks if the startIndex is a candidate for trying a re-match.
584 *
585 *
586 * @param startIndex
587 * @return true if the startIndex should be re-matched, false if not.
588 */
589 private boolean maybeExtendedMatch(final int startIndex) {
590 return (startIndex >= 0) && (startIndex == lastFoundIndex);
591 }
592
593 /**
594 * @param currentResult
595 * @param offset
596 * @return the start position of the selected text
597 */
598 private int updateStateAfterFound(MatchResult currentResult, final int offset) {
599 int end = currentResult.end() + offset;
600 int found = currentResult.start() + offset;
601 select(found, end);
602 getCaret().setSelectionVisible(true);
603 lastFoundIndex = found;
604 lastMatchResult = currentResult;
605 lastRegEx = ((Matcher) lastMatchResult).pattern().pattern();
606 return found;
607 }
608
609 /**
610 * @param matcher
611 * @param useFirst whether or not to return after the first match is found.
612 * @return <code>MatchResult</code> or null
613 */
614 private MatchResult getMatchResult(Matcher matcher, boolean useFirst) {
615 MatchResult currentResult = null;
616 while (matcher.find()) {
617 currentResult = matcher.toMatchResult();
618 if (useFirst) break;
619 }
620 return currentResult;
621 }
622
623 /**
624 */
625 private void updateStateAfterNotFound() {
626 lastFoundIndex = -1;
627 lastMatchResult = null;
628 lastRegEx = null;
629 setCaretPosition(getSelectionEnd());
630 }
631
632 }
633
634 public boolean hasCommand(Object command) {
635 return targetSupport.hasCommand(command);
636 }
637
638 public Object[] getCommands() {
639 return targetSupport.getCommands();
640 }
641
642 public boolean doCommand(Object command, Object value) {
643 return targetSupport.doCommand(command, value);
644 }
645
646 /**
647 * Listens to the caret placement and adjusts the editing
648 * properties as appropriate.
649 *
650 * Should add more attributes as required.
651 */
652 private class CaretHandler implements CaretListener {
653 public void caretUpdate(CaretEvent evt) {
654 StyledDocument document = (StyledDocument)getDocument();
655 int dot = evt.getDot();
656 Element elem = document.getCharacterElement(dot);
657 AttributeSet set = elem.getAttributes();
658
659 ActionManager manager = ActionManager.getInstance();
660 manager.setSelected("font-bold", StyleConstants.isBold(set));
661 manager.setSelected("font-italic", StyleConstants.isItalic(set));
662 manager.setSelected("font-underline", StyleConstants.isUnderline(set));
663
664 elem = document.getParagraphElement(dot);
665 set = elem.getAttributes();
666
667 // Update the paragraph selector if applicable.
668 if (selector != null) {
669 selector.setSelectedItem(set.getAttribute(StyleConstants.NameAttribute));
670 }
671
672 switch (StyleConstants.getAlignment(set)) {
673 // XXX There is a bug here. the setSelected method
674 // should only affect the UI actions rather than propagate
675 // down into the action map actions.
676 case StyleConstants.ALIGN_LEFT:
677 manager.setSelected("left-justify", true);
678 break;
679
680 case StyleConstants.ALIGN_CENTER:
681 manager.setSelected("center-justify", true);
682 break;
683
684 case StyleConstants.ALIGN_RIGHT:
685 manager.setSelected("right-justify", true);
686 break;
687 }
688 }
689 }
690
691 /**
692 * Handles sloppy HTML. This implementation currently only looks for
693 * tags that have a / at the end (self-closing tags) and fixes them
694 * to work with the version of HTML supported by HTMLEditorKit
695 * <p>TODO: Need to break this functionality out so it can take pluggable
696 * replacement code blocks, allowing people to write custom replacement
697 * routines. The idea is that with some simple modifications a lot more
698 * sloppy HTML can be rendered correctly.
699 *
700 * @author rbair
701 */
702 private static final class SloppyHTMLEditorKit extends HTMLEditorKit {
703 public void read(Reader in, Document doc, int pos) throws IOException, BadLocationException {
704 //read the reader into a String
705 StringBuffer buffer = new StringBuffer();
706 int length = -1;
707 char[] data = new char[1024];
708 while ((length = in.read(data)) != -1) {
709 buffer.append(data, 0, length);
710 }
711 //TODO is this regex right?
712 StringReader reader = new StringReader(buffer.toString().replaceAll("/>", ">"));
713 super.read(reader, doc, pos);
714 }
715 }
716 }
717