package net.atlanticbb.tantlinger.ui.text; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Component; import java.awt.Cursor; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.Point; import java.awt.Rectangle; import java.awt.Shape; import java.awt.Stroke; import java.awt.event.InputEvent; import java.awt.event.KeyEvent; import java.awt.event.MouseEvent; import java.util.ArrayList; import java.util.Enumeration; import java.util.HashMap; import java.util.Iterator; import java.util.Map; import javax.swing.Action; import javax.swing.ActionMap; import javax.swing.BorderFactory; import javax.swing.InputMap; import javax.swing.JComponent; import javax.swing.JEditorPane; import javax.swing.JLabel; import javax.swing.KeyStroke; import javax.swing.event.DocumentEvent; import javax.swing.event.MouseInputAdapter; import javax.swing.text.AttributeSet; import javax.swing.text.ComponentView; import javax.swing.text.Document; import javax.swing.text.Element; import javax.swing.text.SimpleAttributeSet; import javax.swing.text.StyleConstants; import javax.swing.text.View; import javax.swing.text.ViewFactory; import javax.swing.text.html.HTML; import javax.swing.text.html.HTMLDocument; import javax.swing.text.html.HTMLEditorKit; import javax.swing.text.html.ObjectView; import net.atlanticbb.tantlinger.ui.UIUtils; import net.atlanticbb.tantlinger.ui.text.actions.DecoratedTextAction; //import net.atlanticbb.tantlinger.ui.text.actions.EnterKeyAction; import net.atlanticbb.tantlinger.ui.text.actions.EnterKeyAction; import net.atlanticbb.tantlinger.ui.text.actions.HTMLTextEditAction; import net.atlanticbb.tantlinger.ui.text.actions.RemoveAction; import net.atlanticbb.tantlinger.ui.text.actions.TabAction; /** * An HTML Wysiwyg editor kit which can properly draw borderless tables * and allows for resizing of tables and images. * * @author Bob Tantlinger * */ public class WysiwygHTMLEditorKit extends HTMLEditorKit { /** * */ private static final long serialVersionUID = 1L; private ViewFactory wysFactory = new WysiwygHTMLFactory(); private ArrayList monitoredViews = new ArrayList(); private MouseInputAdapter resizeHandler = new ResizeHandler(); private Map editorToActionsMap = new HashMap(); private KeyStroke tabBackwardKS = KeyStroke.getKeyStroke(KeyEvent.VK_TAB, InputEvent.SHIFT_DOWN_MASK); public WysiwygHTMLEditorKit() { super(); } public Document createDefaultDocument() { HTMLDocument doc = (HTMLDocument)super.createDefaultDocument(); //Unless the following property is set, the HTML parser will throw a //ChangedCharSetException every time a char set tag is encountered. doc.putProperty("IgnoreCharsetDirective", Boolean.TRUE); return doc; } public void install(JEditorPane ed) { super.install(ed); if(editorToActionsMap.containsKey(ed)) return; //already installed ed.addMouseListener(resizeHandler); ed.addMouseMotionListener(resizeHandler); //install wysiwyg actions into the ActionMap for the editor being installed Map actions = new HashMap(); InputMap inputMap = ed.getInputMap(JComponent.WHEN_FOCUSED); ActionMap actionMap = ed.getActionMap(); Action delegate = actionMap.get("insert-break"); Action action = new EnterKeyAction(delegate); actions.put("insert-break", action); actionMap.put("insert-break", action); delegate = actionMap.get("delete-previous"); action = new RemoveAction(RemoveAction.BACKSPACE, delegate); actions.put("delete-previous", action); actionMap.put("delete-previous", action); delegate = actionMap.get("delete-next"); action = new RemoveAction(RemoveAction.DELETE, delegate); actions.put("delete-next", action); actionMap.put("delete-next", action); delegate = actionMap.get("insert-tab"); action = new TabAction(TabAction.FORWARD, delegate); actions.put("insert-tab", action); actionMap.put("insert-tab", action); delegate = actionMap.get("paste-from-clipboard"); HTMLTextEditAction hteAction = new net.atlanticbb.tantlinger.ui.text.actions.PasteAction(); hteAction.putContextValue(HTMLTextEditAction.EDITOR, ed); actions.put("paste-from-clipboard", delegate); actionMap.put("paste-from-clipboard", hteAction); inputMap.put(tabBackwardKS, "tab-backward");//install tab backwards keystroke action = new TabAction(TabAction.BACKWARD, delegate); actions.put("tab-backward", action); actionMap.put("tab-backward", action); editorToActionsMap.put(ed, actions); } public void deinstall(JEditorPane ed) { super.deinstall(ed); if(!editorToActionsMap.containsKey(ed)) return; //not installed installed ed.removeMouseListener(resizeHandler); ed.removeMouseMotionListener(resizeHandler); //restore actions to their original state ActionMap actionMap = ed.getActionMap(); Map actions = (Map)editorToActionsMap.get(ed); Action curAct = actionMap.get("insert-break"); if(curAct == actions.get("insert-break")) { actionMap.put("insert-break", ((DecoratedTextAction)curAct).getDelegate()); } curAct = actionMap.get("delete-previous"); if(curAct == actions.get("delete-previous")) { actionMap.put("delete-previous", ((DecoratedTextAction)curAct).getDelegate()); } curAct = actionMap.get("delete-next"); if(curAct == actions.get("delete-next")) { actionMap.put("delete-next", ((DecoratedTextAction)curAct).getDelegate()); } curAct = actionMap.get("insert-tab"); if(curAct == actions.get("insert-tab")) { actionMap.put("insert-tab", ((DecoratedTextAction)curAct).getDelegate()); } curAct = actionMap.get("paste-from-clipboard"); if(curAct instanceof net.atlanticbb.tantlinger.ui.text.actions.PasteAction) { actionMap.put("paste-from-clipboard", (Action)actions.get("paste-from-clipboard")); } curAct = actionMap.get("tab-backward"); if(curAct == actions.get("insert-tab")) { actionMap.remove("tab-backward"); //inputMap.remove(tabBackwardKS);//remove backwards keystroke } editorToActionsMap.remove(ed); } /** * Fetch a factory that is suitable for producing views * of any models that are produced by this kit. * @return the factory */ public ViewFactory getViewFactory() { return wysFactory; } /** * Factory to build views of the html elements. This simply extends the behavior * of the default html factory to draw borderless tables, etc */ public class WysiwygHTMLFactory extends HTMLFactory { public View create(Element elem) { Object o = elem.getAttributes().getAttribute(StyleConstants.NameAttribute); if(o instanceof HTML.Tag) { HTML.Tag kind = (HTML.Tag)o; if(kind == HTML.Tag.TABLE) { ResizableView v = new ResizableView( new BorderlessTableView(super.create(elem))); monitoredViews.add(v); return v; } else if(kind == HTML.Tag.IMG) { ResizableView v = new ResizableView(super.create(elem)); monitoredViews.add(v); return v; } else if(kind == HTML.Tag.COMMENT) { return new UnknownElementView((elem)); } else if(kind == HTML.Tag.OBJECT) { ObjectView ov = new ObjectView(elem) { //make a nicer looking representation for <object>. //The default is a crappy red JLabel with "??" as the text protected Component createComponent() { Component comp = super.createComponent(); if(comp instanceof JLabel) { JLabel l = (JLabel)comp; if(l.getText().equals("??") && l.getForeground().equals(Color.red)) { l.setIcon(UIUtils.getIcon(UIUtils.X24, "cogs.png")); l.setText(null); l.setBackground(Color.YELLOW); l.setOpaque(true); l.setBorder(BorderFactory.createRaisedBevelBorder()); l.setToolTipText("<object></object>"); } } return comp; } }; return ov; } else if((kind instanceof HTML.UnknownTag) || (kind == HTML.Tag.TITLE) || (kind == HTML.Tag.META) || (kind == HTML.Tag.LINK) || (kind == HTML.Tag.STYLE) || (kind == HTML.Tag.SCRIPT) || (kind == HTML.Tag.AREA) || (kind == HTML.Tag.MAP) || (kind == HTML.Tag.PARAM) || (kind == HTML.Tag.APPLET)) { return new UnknownElementView(elem); } } return super.create(elem); } } /** * Handle the resizing of images and tables * */ private class ResizeHandler extends MouseInputAdapter { boolean dragStarted; //need down flag for Java 1.4 - mouseMoved gets called during a drag boolean mouseDown; int dragDir = -1; public void mousePressed(MouseEvent e) { mouseDown = true; boolean selected = false; //iterate thru the list backwards to select //most recently added views so nested tables get selected properly for(int i = monitoredViews.size() - 1; i >= 0; i--) { ResizableView v = (ResizableView)monitoredViews.get(i); Rectangle r = v.getBounds(); if(r != null && r.contains(e.getPoint()) && !selected) { v.setSelectionEnabled(true); dragDir = v.getHandleForPoint(e.getPoint()); setCursorForDir(dragDir, e.getComponent()); selected = true; } else v.setSelectionEnabled(false); } e.getComponent().repaint(); } public void mouseMoved(MouseEvent e) { if(!mouseDown) { ResizableView v = getSelectedView(); if(v == null) return; Component c = e.getComponent(); setCursorForDir(v.getHandleForPoint(e.getPoint()), c); } } public void mouseDragged(MouseEvent e) { dragStarted = dragDir != -1; ResizableView v = getSelectedView(); if(v == null || !dragStarted) return; Rectangle r = v.getSelectionBounds(); if(dragDir == ResizableView.SE) { r.width = e.getX() - r.x; r.height = e.getY() - r.y; } else if(dragDir == ResizableView.NE) { r.width = e.getX() - r.x; r.height = (r.y + r.height) - e.getY(); r.y = e.getY(); } else if(dragDir == ResizableView.SW) { r.width = (r.x + r.width) - e.getX(); r.height = e.getY() - r.y; r.x = e.getX(); } else if(dragDir == ResizableView.NW) { r.width = (r.x + r.width) - e.getX(); r.height = (r.y + r.height) - e.getY(); r.x = e.getX(); r.y = e.getY(); } else if(dragDir == ResizableView.N) { r.height = (r.y + r.height) - e.getY(); r.y = e.getY(); } else if(dragDir == ResizableView.S) { r.height = e.getY() - r.y; } else if(dragDir == ResizableView.E) { r.width = e.getX() - r.x; } else if(dragDir == ResizableView.W) { r.width = (r.x + r.width) - e.getX(); r.x = e.getX(); } e.getComponent().repaint(); } public void mouseReleased(MouseEvent e) { mouseDown = false; ResizableView v = getSelectedView(); if(v != null && dragStarted) { Element elem = v.getElement(); SimpleAttributeSet sas = new SimpleAttributeSet(elem.getAttributes()); Integer w = new Integer(v.getSelectionBounds().width); Integer h = new Integer(v.getSelectionBounds().height); if(elem.getName().equals("table"))//resize the table { //currently jeditorpane only supports the width attrib for tables sas.addAttribute(HTML.Attribute.WIDTH, w); String html = HTMLUtils.getElementHTML(elem, false); html = HTMLUtils.createTag(HTML.Tag.TABLE, sas, html); replace(elem, html); } else if(elem.getName().equals("img"))//resize the img { sas.addAttribute(HTML.Attribute.WIDTH, w); sas.addAttribute(HTML.Attribute.HEIGHT, h); String html = "<img"; for(Enumeration ee = sas.getAttributeNames(); ee.hasMoreElements();) { Object name = ee.nextElement(); if(!(name.toString().equals("name") || name.toString().equals("a"))) { Object val = sas.getAttribute(name); html += " " + name + "=\"" + val + "\""; } } html += ">"; if(sas.isDefined(HTML.Tag.A)) html = "<a " + sas.getAttribute(HTML.Tag.A) + ">" + html + "</a>"; replace(elem, html); } //remove views not appearing in the doc updateMonitoredViews((HTMLDocument)v.getDocument()); } dragStarted = false; } private void setCursorForDir(int d, Component c) { if(d == ResizableView.NW) c.setCursor(Cursor.getPredefinedCursor(Cursor.NW_RESIZE_CURSOR)); else if(d == ResizableView.SW) c.setCursor(Cursor.getPredefinedCursor(Cursor.SW_RESIZE_CURSOR)); else if(d == ResizableView.NE) c.setCursor(Cursor.getPredefinedCursor(Cursor.NE_RESIZE_CURSOR)); else if(d == ResizableView.SE) c.setCursor(Cursor.getPredefinedCursor(Cursor.SE_RESIZE_CURSOR)); else if(d == ResizableView.N) c.setCursor(Cursor.getPredefinedCursor(Cursor.N_RESIZE_CURSOR)); else if(d == ResizableView.S) c.setCursor(Cursor.getPredefinedCursor(Cursor.S_RESIZE_CURSOR)); else if(d == ResizableView.E) c.setCursor(Cursor.getPredefinedCursor(Cursor.E_RESIZE_CURSOR)); else if(d == ResizableView.W) c.setCursor(Cursor.getPredefinedCursor(Cursor.W_RESIZE_CURSOR)); else if(c.getCursor().getType() != Cursor.DEFAULT_CURSOR) c.setCursor(Cursor.getDefaultCursor()); } /** * Updates the list of monitored ResizeableViews. If they don't * exist in the document, they're removed from the list. * @param doc */ private void updateMonitoredViews(HTMLDocument doc) { for(Iterator it = monitoredViews.iterator(); it.hasNext();) { View v = (View)it.next(); Element vElem = v.getElement(); if(vElem.getName().equals("img")) { Element el = doc.getCharacterElement(vElem.getStartOffset()); if(el != vElem) it.remove(); } else if(vElem.getName().equals("table")) { Element el = doc.getParagraphElement(vElem.getStartOffset()); //get the parent and check if its the same element el = HTMLUtils.getParent(el, HTML.Tag.TABLE); //FIXME if the element is a nested table in the first cell //of the parent table, the parent view is removed if(el != vElem) it.remove(); } } } /** * Get the currently selected view. * Only one view at a time can be selected * @return */ private ResizableView getSelectedView() { for(Iterator it = monitoredViews.iterator(); it.hasNext();) { ResizableView v = (ResizableView)it.next(); if(v.isSelectionEnabled()) return v; } return null; } /** * Replaced the element with the specified html. * @param elem * @param html */ private void replace(Element elem, String html) { HTMLDocument document = (HTMLDocument)elem.getDocument(); CompoundUndoManager.beginCompoundEdit(document); try { document.setOuterHTML(elem, html); } catch(Exception ex) { ex.printStackTrace(); } CompoundUndoManager.endCompoundEdit(document); } } /** * View which can draw resize handles around its delegate * * @author Bob Tantlinger */ private class ResizableView extends DelegateView { public static final int NW = 0; public static final int NE = 1; public static final int SW = 2; public static final int SE = 3; public static final int N = 4; public static final int S = 5; public static final int E = 6; public static final int W = 7; private Rectangle curBounds; private Rectangle selBounds; public ResizableView(View delegate) { super(delegate); } public void paint(Graphics g, Shape allocation) { curBounds = new Rectangle(allocation.getBounds()); delegate.paint(g, allocation); drawSelectionHandles(g); } /* (non-Javadoc) * @see javax.swing.text.View#insertUpdate(javax.swing.event.DocumentEvent, java.awt.Shape, javax.swing.text.ViewFactory) */ public void insertUpdate(DocumentEvent e, Shape a, ViewFactory f) { setSelectionEnabled(false); super.insertUpdate(e, a, f); } /* (non-Javadoc) * @see javax.swing.text.View#changedUpdate(javax.swing.event.DocumentEvent, java.awt.Shape, javax.swing.text.ViewFactory) */ public void changedUpdate(DocumentEvent e, Shape a, ViewFactory f) { setSelectionEnabled(false); super.changedUpdate(e, a, f); } /* (non-Javadoc) * @see javax.swing.text.View#removeUpdate(javax.swing.event.DocumentEvent, java.awt.Shape, javax.swing.text.ViewFactory) */ public void removeUpdate(DocumentEvent e, Shape a, ViewFactory f) { setSelectionEnabled(false); super.removeUpdate(e, a, f); } /** * Gets the current bounds of the view * @return */ public Rectangle getBounds() { return curBounds; } /** * Gets the Rectangle from which the selection handles are drawn * @return */ public Rectangle getSelectionBounds() { return selBounds; } /** * Draw the selection if true * @param b */ public void setSelectionEnabled(boolean b) { if(b && curBounds != null) selBounds = new Rectangle(curBounds); else selBounds = null; } public boolean isSelectionEnabled() { return selBounds != null; } /** * Gets the selection handle at the specified point. * @param p * @return one of NW, SW, NE, SE, N, S, E, W * or -1 if a handle is not at the point */ public int getHandleForPoint(Point p) { if(isSelectionEnabled()) { Rectangle r[] = computeHandles(selBounds); for(int i = 0; i < r.length; i++) if(r[i].contains(p)) return i; } return -1; } private void drawSelectionHandles(Graphics g) { if(!isSelectionEnabled()) return; Color cached = g.getColor(); g.setColor(Color.DARK_GRAY); g.drawRect(selBounds.x, selBounds.y, selBounds.width, selBounds.height); Rectangle h[] = computeHandles(selBounds); for(int i = 0; i < h.length; i++) g.fillRect(h[i].x, h[i].y, h[i].width, h[i].height); g.setColor(cached); } private Rectangle[] computeHandles(Rectangle sel) { Rectangle r[] = new Rectangle[8]; int sq = 8; r[NW] = new Rectangle(sel.x, sel.y, sq, sq); r[NE] = new Rectangle(sel.x + sel.width - sq, sel.y, sq, sq); r[SW] = new Rectangle(sel.x, sel.y + sel.height - sq, sq, sq); r[SE] = new Rectangle(sel.x + sel.width - sq, sel.y + sel.height - sq, sq, sq); int midX = sel.x + (sel.width/2 - sq/2); int midY = sel.y + (sel.height/2 - sq/2); r[N] = new Rectangle(midX, sel.y, sq, sq); r[S] = new Rectangle(midX, sel.y + sel.height - sq, sq, sq); r[E] = new Rectangle(sel.x + sel.width - sq, midY, sq, sq); r[W] = new Rectangle(sel.x, midY, sq, sq); return r; } } /** * Delegate view which draws borderless tables. * * This class is a delegate view because javax.swing.text.html.TableView * is not public... * * @author Bob Tantlinger * */ private class BorderlessTableView extends DelegateView { public BorderlessTableView(View delegate) { super(delegate); } public void paint(Graphics g, Shape allocation) { //if the table element has no border, //then draw the table outline with a dotted line if(shouldDrawDottedBorder()) { //draw the table background color if set //we need to do this for Java 1.5, otherwise //the bgcolor doesnt get painted for some reason Color bgColor = getTableBgcolor(); if(bgColor != null) { Color cachedColor = g.getColor(); g.setColor(bgColor); Rectangle tr = allocation.getBounds(); g.fillRect(tr.x, tr.y, tr.width, tr.height); g.setColor(cachedColor); } delegate.paint(g, allocation); //set up the graphics object to draw dotted lines Graphics2D g2 = (Graphics2D)g; Stroke cachedStroke = g2.getStroke(); float dash[] = {3.0f}; BasicStroke stroke = new BasicStroke( 1f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10f, dash, 0.0f); g2.setStroke(stroke); g2.setColor(Color.DARK_GRAY); int rows = getViewCount(); for(int r = 0; r < rows; r++) { Shape rowShape = getChildAllocation(r, allocation); View rowView = getView(r); int cells = rowView.getViewCount(); //draw each cell with a dotted border for(int c = 0; c < cells; c++) { Shape cellShape = rowView.getChildAllocation(c, rowShape); Rectangle cr = cellShape.getBounds(); g2.drawRect(cr.x, cr.y, cr.width, cr.height); } } g2.setStroke(cachedStroke); } else delegate.paint(g, allocation); } private Color getTableBgcolor() { AttributeSet atr = getElement().getAttributes(); Object o = atr.getAttribute(HTML.Attribute.BGCOLOR); if(o != null) { Color c = HTMLUtils.stringToColor(o.toString()); return c; } return null; } private boolean shouldDrawDottedBorder() { AttributeSet atr = getElement().getAttributes(); boolean isBorderAttr = hasBorderAttr(atr); return (!isBorderAttr) || isBorderAttr && atr.getAttribute(HTML.Attribute.BORDER).toString().equals("0"); } private boolean hasBorderAttr(AttributeSet atr) { for(Enumeration e = atr.getAttributeNames(); e.hasMoreElements();) { if(e.nextElement().toString().equals("border")) return true; } return false; } } /** * A view for {@link Element}s that are uneditable. * <p> * The default view for such {@link Element}s are big ugly blocky JTextFields, * which doesn't really work well in practice and tends to confuse users. * This view replaces the default and simply draws the elements as blocks that * indicates the presence of an uneditable {@link Element}. * <p> * * @author Bob Tantlinger * */ private class UnknownElementView extends ComponentView { public UnknownElementView(Element e) { super(e); } protected Component createComponent() { JLabel p = new JLabel(); if(getElement().getAttributes().getAttribute(StyleConstants.NameAttribute) == HTML.Tag.COMMENT) { p.setText("<!-- -->"); AttributeSet as = getElement().getAttributes(); if(as != null) { Object comment = as.getAttribute(HTML.Attribute.COMMENT); if (comment instanceof String) { p.setToolTipText((String)comment); } } } else { String text = this.getElement().getName(); if(text == null || text.equals("")) text = "??"; if(isEndTag()) text = "</" + text + ">"; else text = "<" + text + ">"; p.setText(text); } p.setBorder(BorderFactory.createRaisedBevelBorder()); p.setBackground(Color.YELLOW); p.setForeground(Color.BLUE); p.setOpaque(true); return p; } boolean isEndTag() { AttributeSet as = getElement().getAttributes(); if(as != null) { Object end = as.getAttribute(HTML.Attribute.ENDTAG); if(end != null && (end instanceof String) && ((String)end).equals("true")) { return true; } } return false; } } }