001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.layer; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.awt.Dimension; 007import java.awt.Graphics2D; 008import java.awt.Point; 009import java.awt.event.MouseEvent; 010import java.awt.event.MouseListener; 011import java.io.File; 012import java.text.DateFormat; 013import java.util.ArrayList; 014import java.util.Collection; 015import java.util.Collections; 016import java.util.List; 017 018import javax.swing.Action; 019import javax.swing.Icon; 020import javax.swing.ImageIcon; 021import javax.swing.JToolTip; 022import javax.swing.SwingUtilities; 023 024import org.openstreetmap.josm.Main; 025import org.openstreetmap.josm.actions.SaveActionBase; 026import org.openstreetmap.josm.data.Bounds; 027import org.openstreetmap.josm.data.notes.Note; 028import org.openstreetmap.josm.data.notes.Note.State; 029import org.openstreetmap.josm.data.notes.NoteComment; 030import org.openstreetmap.josm.data.osm.NoteData; 031import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor; 032import org.openstreetmap.josm.gui.MapView; 033import org.openstreetmap.josm.gui.dialogs.LayerListDialog; 034import org.openstreetmap.josm.gui.dialogs.LayerListPopup; 035import org.openstreetmap.josm.gui.io.AbstractIOTask; 036import org.openstreetmap.josm.gui.io.UploadNoteLayerTask; 037import org.openstreetmap.josm.gui.progress.ProgressMonitor; 038import org.openstreetmap.josm.io.NoteExporter; 039import org.openstreetmap.josm.io.OsmApi; 040import org.openstreetmap.josm.io.XmlWriter; 041import org.openstreetmap.josm.tools.ColorHelper; 042import org.openstreetmap.josm.tools.ImageProvider; 043import org.openstreetmap.josm.tools.Utils; 044import org.openstreetmap.josm.tools.date.DateUtils; 045 046/** 047 * A layer to hold Note objects. 048 * @since 7522 049 */ 050public class NoteLayer extends AbstractModifiableLayer implements MouseListener { 051 052 private final NoteData noteData; 053 054 /** 055 * Create a new note layer with a set of notes 056 * @param notes A list of notes to show in this layer 057 * @param name The name of the layer. Typically "Notes" 058 */ 059 public NoteLayer(Collection<Note> notes, String name) { 060 super(name); 061 noteData = new NoteData(notes); 062 } 063 064 /** Convenience constructor that creates a layer with an empty note list */ 065 public NoteLayer() { 066 this(Collections.<Note>emptySet(), tr("Notes")); 067 } 068 069 @Override 070 public void hookUpMapView() { 071 Main.map.mapView.addMouseListener(this); 072 } 073 074 /** 075 * Returns the note data store being used by this layer 076 * @return noteData containing layer notes 077 */ 078 public NoteData getNoteData() { 079 return noteData; 080 } 081 082 @Override 083 public boolean isModified() { 084 return noteData.isModified(); 085 } 086 087 @Override 088 public boolean isUploadable() { 089 return true; 090 } 091 092 @Override 093 public boolean requiresUploadToServer() { 094 return isModified(); 095 } 096 097 @Override 098 public boolean isSavable() { 099 return true; 100 } 101 102 @Override 103 public boolean requiresSaveToFile() { 104 return getAssociatedFile() != null && isModified(); 105 } 106 107 @Override 108 public void paint(Graphics2D g, MapView mv, Bounds box) { 109 final int iconHeight = ImageProvider.ImageSizes.SMALLICON.getAdjustedHeight(); 110 final int iconWidth = ImageProvider.ImageSizes.SMALLICON.getAdjustedWidth(); 111 112 for (Note note : noteData.getNotes()) { 113 Point p = mv.getPoint(note.getLatLon()); 114 115 ImageIcon icon; 116 if (note.getId() < 0) { 117 icon = ImageProvider.get("dialogs/notes", "note_new", ImageProvider.ImageSizes.SMALLICON); 118 } else if (note.getState() == State.CLOSED) { 119 icon = ImageProvider.get("dialogs/notes", "note_closed", ImageProvider.ImageSizes.SMALLICON); 120 } else { 121 icon = ImageProvider.get("dialogs/notes", "note_open", ImageProvider.ImageSizes.SMALLICON); 122 } 123 int width = icon.getIconWidth(); 124 int height = icon.getIconHeight(); 125 g.drawImage(icon.getImage(), p.x - (width / 2), p.y - height, Main.map.mapView); 126 } 127 if (noteData.getSelectedNote() != null) { 128 StringBuilder sb = new StringBuilder("<html>"); 129 sb.append(tr("Note")) 130 .append(' ').append(noteData.getSelectedNote().getId()); 131 for (NoteComment comment : noteData.getSelectedNote().getComments()) { 132 String commentText = comment.getText(); 133 //closing a note creates an empty comment that we don't want to show 134 if (commentText != null && !commentText.trim().isEmpty()) { 135 sb.append("<hr/>"); 136 String userName = XmlWriter.encode(comment.getUser().getName()); 137 if (userName == null || userName.trim().isEmpty()) { 138 userName = "<Anonymous>"; 139 } 140 sb.append(userName); 141 sb.append(" on "); 142 sb.append(DateUtils.getDateFormat(DateFormat.MEDIUM).format(comment.getCommentTimestamp())); 143 sb.append(":<br/>"); 144 String htmlText = XmlWriter.encode(comment.getText(), true); 145 htmlText = htmlText.replace("
", "<br/>"); //encode method leaves us with entity instead of \n 146 htmlText = htmlText.replace("/", "/\u200b"); //zero width space to wrap long URLs (see #10864) 147 sb.append(htmlText); 148 } 149 } 150 sb.append("</html>"); 151 JToolTip toolTip = new JToolTip(); 152 toolTip.setTipText(sb.toString()); 153 Point p = mv.getPoint(noteData.getSelectedNote().getLatLon()); 154 155 g.setColor(ColorHelper.html2color(Main.pref.get("color.selected"))); 156 g.drawRect(p.x - (iconWidth / 2), p.y - iconHeight, 157 iconWidth - 1, iconHeight - 1); 158 159 int tx = p.x + (iconWidth / 2) + 5; 160 int ty = p.y - iconHeight - 1; 161 g.translate(tx, ty); 162 163 //Carried over from the OSB plugin. Not entirely sure why it is needed 164 //but without it, the tooltip doesn't get sized correctly 165 for (int x = 0; x < 2; x++) { 166 Dimension d = toolTip.getUI().getPreferredSize(toolTip); 167 d.width = Math.min(d.width, mv.getWidth() / 2); 168 if (d.width > 0 && d.height > 0) { 169 toolTip.setSize(d); 170 try { 171 toolTip.paint(g); 172 } catch (IllegalArgumentException e) { 173 // See #11123 - https://bugs.openjdk.java.net/browse/JDK-6719550 174 // Ignore the exception, as Netbeans does: http://hg.netbeans.org/main-silver/rev/c96f4d5fbd20 175 Main.error(e, false); 176 } 177 } 178 } 179 g.translate(-tx, -ty); 180 } 181 } 182 183 @Override 184 public Icon getIcon() { 185 return ImageProvider.get("dialogs/notes", "note_open", ImageProvider.ImageSizes.SMALLICON); 186 } 187 188 @Override 189 public String getToolTipText() { 190 return noteData.getNotes().size() + ' ' + tr("Notes"); 191 } 192 193 @Override 194 public void mergeFrom(Layer from) { 195 throw new UnsupportedOperationException("Notes layer does not support merging yet"); 196 } 197 198 @Override 199 public boolean isMergable(Layer other) { 200 return false; 201 } 202 203 @Override 204 public void visitBoundingBox(BoundingXYVisitor v) { 205 for (Note note : noteData.getNotes()) { 206 v.visit(note.getLatLon()); 207 } 208 } 209 210 @Override 211 public Object getInfoComponent() { 212 StringBuilder sb = new StringBuilder(); 213 sb.append(tr("Notes layer")) 214 .append('\n') 215 .append(tr("Total notes:")) 216 .append(' ') 217 .append(noteData.getNotes().size()) 218 .append('\n') 219 .append(tr("Changes need uploading?")) 220 .append(' ') 221 .append(isModified()); 222 return sb.toString(); 223 } 224 225 @Override 226 public Action[] getMenuEntries() { 227 List<Action> actions = new ArrayList<>(); 228 actions.add(LayerListDialog.getInstance().createShowHideLayerAction()); 229 actions.add(LayerListDialog.getInstance().createDeleteLayerAction()); 230 actions.add(new LayerListPopup.InfoAction(this)); 231 actions.add(new LayerSaveAction(this)); 232 actions.add(new LayerSaveAsAction(this)); 233 return actions.toArray(new Action[actions.size()]); 234 } 235 236 @Override 237 public void mouseClicked(MouseEvent e) { 238 if (SwingUtilities.isRightMouseButton(e) && noteData.getSelectedNote() != null) { 239 final String url = OsmApi.getOsmApi().getBaseUrl() + "notes/" + noteData.getSelectedNote().getId(); 240 Utils.copyToClipboard(url); 241 return; 242 } else if (!SwingUtilities.isLeftMouseButton(e)) { 243 return; 244 } 245 Point clickPoint = e.getPoint(); 246 double snapDistance = 10; 247 double minDistance = Double.MAX_VALUE; 248 final int iconHeight = ImageProvider.ImageSizes.SMALLICON.getAdjustedHeight(); 249 Note closestNote = null; 250 for (Note note : noteData.getNotes()) { 251 Point notePoint = Main.map.mapView.getPoint(note.getLatLon()); 252 //move the note point to the center of the icon where users are most likely to click when selecting 253 notePoint.setLocation(notePoint.getX(), notePoint.getY() - iconHeight / 2); 254 double dist = clickPoint.distanceSq(notePoint); 255 if (minDistance > dist && clickPoint.distance(notePoint) < snapDistance) { 256 minDistance = dist; 257 closestNote = note; 258 } 259 } 260 noteData.setSelectedNote(closestNote); 261 } 262 263 @Override 264 public File createAndOpenSaveFileChooser() { 265 return SaveActionBase.createAndOpenSaveFileChooser(tr("Save GPX file"), NoteExporter.FILE_FILTER); 266 } 267 268 @Override 269 public AbstractIOTask createUploadTask(ProgressMonitor monitor) { 270 return new UploadNoteLayerTask(this, monitor); 271 } 272 273 @Override 274 public void mousePressed(MouseEvent e) { 275 // Do nothing 276 } 277 278 @Override 279 public void mouseReleased(MouseEvent e) { 280 // Do nothing 281 } 282 283 @Override 284 public void mouseEntered(MouseEvent e) { 285 // Do nothing 286 } 287 288 @Override 289 public void mouseExited(MouseEvent e) { 290 // Do nothing 291 } 292}