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