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