001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.layer; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005import static org.openstreetmap.josm.tools.I18n.trn; 006 007import java.awt.Color; 008import java.awt.Dimension; 009import java.awt.Graphics2D; 010import java.awt.Point; 011import java.awt.event.MouseEvent; 012import java.awt.event.MouseListener; 013import java.awt.event.MouseWheelEvent; 014import java.awt.event.MouseWheelListener; 015import java.io.File; 016import java.text.DateFormat; 017import java.util.ArrayList; 018import java.util.Collection; 019import java.util.Collections; 020import java.util.List; 021import java.util.regex.Matcher; 022import java.util.regex.Pattern; 023 024import javax.swing.Action; 025import javax.swing.BorderFactory; 026import javax.swing.Icon; 027import javax.swing.ImageIcon; 028import javax.swing.JEditorPane; 029import javax.swing.JWindow; 030import javax.swing.SwingUtilities; 031import javax.swing.UIManager; 032import javax.swing.plaf.basic.BasicHTML; 033import javax.swing.text.View; 034 035import org.openstreetmap.josm.Main; 036import org.openstreetmap.josm.actions.SaveActionBase; 037import org.openstreetmap.josm.data.Bounds; 038import org.openstreetmap.josm.data.notes.Note; 039import org.openstreetmap.josm.data.notes.Note.State; 040import org.openstreetmap.josm.data.notes.NoteComment; 041import org.openstreetmap.josm.data.osm.NoteData; 042import org.openstreetmap.josm.data.osm.NoteData.NoteDataUpdateListener; 043import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor; 044import org.openstreetmap.josm.gui.MainApplication; 045import org.openstreetmap.josm.gui.MainFrame; 046import org.openstreetmap.josm.gui.MapView; 047import org.openstreetmap.josm.gui.dialogs.LayerListDialog; 048import org.openstreetmap.josm.gui.dialogs.LayerListPopup; 049import org.openstreetmap.josm.gui.io.AbstractIOTask; 050import org.openstreetmap.josm.gui.io.UploadNoteLayerTask; 051import org.openstreetmap.josm.gui.io.importexport.NoteExporter; 052import org.openstreetmap.josm.gui.progress.ProgressMonitor; 053import org.openstreetmap.josm.gui.widgets.HtmlPanel; 054import org.openstreetmap.josm.io.XmlWriter; 055import org.openstreetmap.josm.spi.preferences.Config; 056import org.openstreetmap.josm.tools.ColorHelper; 057import org.openstreetmap.josm.tools.ImageProvider; 058import org.openstreetmap.josm.tools.Logging; 059import org.openstreetmap.josm.tools.date.DateUtils; 060 061/** 062 * A layer to hold Note objects. 063 * @since 7522 064 */ 065public class NoteLayer extends AbstractModifiableLayer implements MouseListener, NoteDataUpdateListener { 066 067 /** 068 * Pattern to detect end of sentences followed by another one, or a link, in western script. 069 * Group 1 (capturing): period, interrogation mark, exclamation mark 070 * Group non capturing: at least one horizontal or vertical whitespace 071 * Group 2 (capturing): a letter (any script), or any punctuation 072 */ 073 private static final Pattern SENTENCE_MARKS_WESTERN = Pattern.compile("([\\.\\?\\!])(?:[\\h\\v]+)([\\p{L}\\p{Punct}])"); 074 075 /** 076 * Pattern to detect end of sentences followed by another one, or a link, in eastern script. 077 * Group 1 (capturing): ideographic full stop 078 * Group 2 (capturing): a letter (any script), or any punctuation 079 */ 080 private static final Pattern SENTENCE_MARKS_EASTERN = Pattern.compile("(\\u3002)([\\p{L}\\p{Punct}])"); 081 082 private static final Pattern HTTP_LINK = Pattern.compile("(https?://[^\\s\\(\\)<>]+)"); 083 private static final Pattern HTML_LINK = Pattern.compile("<a href=\"[^\"]+\">([^<]+)</a>"); 084 private static final Pattern HTML_LINK_MARK = Pattern.compile("<a href=\"([^\"]+)([\\.\\?\\!])\">([^<]+)(?:[\\.\\?\\!])</a>"); 085 private static final Pattern SLASH = Pattern.compile("([^/])/([^/])"); 086 087 private final NoteData noteData; 088 089 private Note displayedNote; 090 private HtmlPanel displayedPanel; 091 private JWindow displayedWindow; 092 093 /** 094 * Create a new note layer with a set of notes 095 * @param notes A list of notes to show in this layer 096 * @param name The name of the layer. Typically "Notes" 097 */ 098 public NoteLayer(Collection<Note> notes, String name) { 099 super(name); 100 noteData = new NoteData(notes); 101 noteData.addNoteDataUpdateListener(this); 102 } 103 104 /** Convenience constructor that creates a layer with an empty note list */ 105 public NoteLayer() { 106 this(Collections.<Note>emptySet(), tr("Notes")); 107 } 108 109 @Override 110 public void hookUpMapView() { 111 MainApplication.getMap().mapView.addMouseListener(this); 112 } 113 114 @Override 115 public synchronized void destroy() { 116 MainApplication.getMap().mapView.removeMouseListener(this); 117 noteData.removeNoteDataUpdateListener(this); 118 hideNoteWindow(); 119 super.destroy(); 120 } 121 122 /** 123 * Returns the note data store being used by this layer 124 * @return noteData containing layer notes 125 */ 126 public NoteData getNoteData() { 127 return noteData; 128 } 129 130 @Override 131 public boolean isModified() { 132 return noteData.isModified(); 133 } 134 135 @Override 136 public boolean isDownloadable() { 137 return true; 138 } 139 140 @Override 141 public boolean isUploadable() { 142 return true; 143 } 144 145 @Override 146 public boolean requiresUploadToServer() { 147 return isModified(); 148 } 149 150 @Override 151 public boolean isSavable() { 152 return true; 153 } 154 155 @Override 156 public boolean requiresSaveToFile() { 157 return getAssociatedFile() != null && isModified(); 158 } 159 160 @Override 161 public void paint(Graphics2D g, MapView mv, Bounds box) { 162 final int iconHeight = ImageProvider.ImageSizes.SMALLICON.getAdjustedHeight(); 163 final int iconWidth = ImageProvider.ImageSizes.SMALLICON.getAdjustedWidth(); 164 165 for (Note note : noteData.getNotes()) { 166 Point p = mv.getPoint(note.getLatLon()); 167 168 ImageIcon icon; 169 if (note.getId() < 0) { 170 icon = ImageProvider.get("dialogs/notes", "note_new", ImageProvider.ImageSizes.SMALLICON); 171 } else if (note.getState() == State.CLOSED) { 172 icon = ImageProvider.get("dialogs/notes", "note_closed", ImageProvider.ImageSizes.SMALLICON); 173 } else { 174 icon = ImageProvider.get("dialogs/notes", "note_open", ImageProvider.ImageSizes.SMALLICON); 175 } 176 int width = icon.getIconWidth(); 177 int height = icon.getIconHeight(); 178 g.drawImage(icon.getImage(), p.x - (width / 2), p.y - height, MainApplication.getMap().mapView); 179 } 180 Note selectedNote = noteData.getSelectedNote(); 181 if (selectedNote != null) { 182 paintSelectedNote(g, mv, iconHeight, iconWidth, selectedNote); 183 } else { 184 hideNoteWindow(); 185 } 186 } 187 188 private void hideNoteWindow() { 189 if (displayedWindow != null) { 190 displayedWindow.setVisible(false); 191 for (MouseWheelListener listener : displayedWindow.getMouseWheelListeners()) { 192 displayedWindow.removeMouseWheelListener(listener); 193 } 194 displayedWindow.dispose(); 195 displayedWindow = null; 196 displayedPanel = null; 197 displayedNote = null; 198 } 199 } 200 201 private void paintSelectedNote(Graphics2D g, MapView mv, final int iconHeight, final int iconWidth, Note selectedNote) { 202 Point p = mv.getPoint(selectedNote.getLatLon()); 203 204 g.setColor(ColorHelper.html2color(Config.getPref().get("color.selected"))); 205 g.drawRect(p.x - (iconWidth / 2), p.y - iconHeight, iconWidth - 1, iconHeight - 1); 206 207 if (displayedNote != null && !displayedNote.equals(selectedNote)) { 208 hideNoteWindow(); 209 } 210 211 int xl = p.x - (iconWidth / 2) - 5; 212 int xr = p.x + (iconWidth / 2) + 5; 213 int yb = p.y - iconHeight - 1; 214 int yt = p.y + (iconHeight / 2) + 2; 215 Point pTooltip; 216 217 String text = getNoteToolTip(selectedNote); 218 219 if (displayedWindow == null) { 220 displayedPanel = new HtmlPanel(text); 221 displayedPanel.setBackground(UIManager.getColor("ToolTip.background")); 222 displayedPanel.setForeground(UIManager.getColor("ToolTip.foreground")); 223 displayedPanel.setFont(UIManager.getFont("ToolTip.font")); 224 displayedPanel.setBorder(BorderFactory.createLineBorder(Color.black)); 225 displayedPanel.enableClickableHyperlinks(); 226 pTooltip = fixPanelSizeAndLocation(mv, text, xl, xr, yt, yb); 227 displayedWindow = new JWindow((MainFrame) Main.parent); 228 displayedWindow.setAutoRequestFocus(false); 229 displayedWindow.add(displayedPanel); 230 // Forward mouse wheel scroll event to MapMover 231 displayedWindow.addMouseWheelListener(e -> mv.getMapMover().mouseWheelMoved( 232 (MouseWheelEvent) SwingUtilities.convertMouseEvent(displayedWindow, e, mv))); 233 } else { 234 displayedPanel.setText(text); 235 pTooltip = fixPanelSizeAndLocation(mv, text, xl, xr, yt, yb); 236 } 237 238 displayedWindow.pack(); 239 displayedWindow.setLocation(pTooltip); 240 displayedWindow.setVisible(mv.contains(p)); 241 displayedNote = selectedNote; 242 } 243 244 private Point fixPanelSizeAndLocation(MapView mv, String text, int xl, int xr, int yt, int yb) { 245 int leftMaxWidth = (int) (0.95 * xl); 246 int rightMaxWidth = (int) (0.95 * mv.getWidth() - xr); 247 int topMaxHeight = (int) (0.95 * yt); 248 int bottomMaxHeight = (int) (0.95 * mv.getHeight() - yb); 249 int maxWidth = Math.max(leftMaxWidth, rightMaxWidth); 250 int maxHeight = Math.max(topMaxHeight, bottomMaxHeight); 251 JEditorPane pane = displayedPanel.getEditorPane(); 252 Dimension d = pane.getPreferredSize(); 253 if ((d.width > maxWidth || d.height > maxHeight) && Config.getPref().getBoolean("note.text.break-on-sentence-mark", false)) { 254 // To make sure long notes are displayed correctly 255 displayedPanel.setText(insertLineBreaks(text)); 256 } 257 // If still too large, enforce maximum size 258 d = pane.getPreferredSize(); 259 if (d.width > maxWidth || d.height > maxHeight) { 260 View v = (View) pane.getClientProperty(BasicHTML.propertyKey); 261 if (v == null) { 262 BasicHTML.updateRenderer(pane, text); 263 v = (View) pane.getClientProperty(BasicHTML.propertyKey); 264 } 265 if (v != null) { 266 v.setSize(maxWidth, 0); 267 int w = (int) Math.ceil(v.getPreferredSpan(View.X_AXIS)); 268 int h = (int) Math.ceil(v.getPreferredSpan(View.Y_AXIS)) + 10; 269 pane.setPreferredSize(new Dimension(w, h)); 270 } 271 } 272 d = pane.getPreferredSize(); 273 // place tooltip on left or right side of icon, based on its width 274 Point screenloc = mv.getLocationOnScreen(); 275 return new Point( 276 screenloc.x + (d.width > rightMaxWidth && d.width <= leftMaxWidth ? xl - d.width : xr), 277 screenloc.y + (d.height > bottomMaxHeight && d.height <= topMaxHeight ? yt - d.height - 10 : yb)); 278 } 279 280 /** 281 * Inserts HTML line breaks ({@code <br>} at the end of each sentence mark 282 * (period, interrogation mark, exclamation mark, ideographic full stop). 283 * @param longText a long text that does not fit on a single line without exceeding half of the map view 284 * @return text with line breaks 285 */ 286 static String insertLineBreaks(String longText) { 287 return SENTENCE_MARKS_WESTERN.matcher(SENTENCE_MARKS_EASTERN.matcher(longText).replaceAll("$1<br>$2")).replaceAll("$1<br>$2"); 288 } 289 290 /** 291 * Returns the HTML-formatted tooltip text for the given note. 292 * @param note note to display 293 * @return the HTML-formatted tooltip text for the given note 294 * @since 13111 295 */ 296 public static String getNoteToolTip(Note note) { 297 StringBuilder sb = new StringBuilder("<html>"); 298 sb.append(tr("Note")) 299 .append(' ').append(note.getId()); 300 for (NoteComment comment : note.getComments()) { 301 String commentText = comment.getText(); 302 //closing a note creates an empty comment that we don't want to show 303 if (commentText != null && !commentText.trim().isEmpty()) { 304 sb.append("<hr/>"); 305 String userName = XmlWriter.encode(comment.getUser().getName()); 306 if (userName == null || userName.trim().isEmpty()) { 307 userName = "<Anonymous>"; 308 } 309 sb.append(userName) 310 .append(" on ") 311 .append(DateUtils.getDateFormat(DateFormat.MEDIUM).format(comment.getCommentTimestamp())) 312 .append(":<br>"); 313 String htmlText = XmlWriter.encode(comment.getText(), true); 314 // encode method leaves us with entity instead of \n 315 htmlText = htmlText.replace("
", "<br>"); 316 // convert URLs to proper HTML links 317 htmlText = replaceLinks(htmlText); 318 sb.append(htmlText); 319 } 320 } 321 sb.append("</html>"); 322 String result = sb.toString(); 323 Logging.debug(result); 324 return result; 325 } 326 327 static String replaceLinks(String htmlText) { 328 String result = HTTP_LINK.matcher(htmlText).replaceAll("<a href=\"$1\">$1</a>"); 329 result = HTML_LINK_MARK.matcher(result).replaceAll("<a href=\"$1\">$3</a>$2"); 330 Matcher m1 = HTML_LINK.matcher(result); 331 if (m1.find()) { 332 int last = 0; 333 StringBuffer sb = new StringBuffer(); // Switch to StringBuilder when switching to Java 9 334 do { 335 sb.append(result, last, m1.start()); 336 last = m1.end(); 337 String link = m1.group(0); 338 Matcher m2 = SLASH.matcher(link).region(link.indexOf('>'), link.lastIndexOf('<')); 339 while (m2.find()) { 340 m2.appendReplacement(sb, "$1/\u200b$2"); //zero width space to wrap long URLs (see #10864, #15550) 341 } 342 m2.appendTail(sb); 343 } while (m1.find()); 344 result = sb.append(result, last, result.length()).toString(); 345 } 346 return result; 347 } 348 349 @Override 350 public Icon getIcon() { 351 return ImageProvider.get("dialogs/notes", "note_open", ImageProvider.ImageSizes.SMALLICON); 352 } 353 354 @Override 355 public String getToolTipText() { 356 int size = noteData.getNotes().size(); 357 return trn("{0} note", "{0} notes", size, size); 358 } 359 360 @Override 361 public void mergeFrom(Layer from) { 362 if (from instanceof NoteLayer && this != from) { 363 noteData.mergeFrom(((NoteLayer) from).noteData); 364 } 365 } 366 367 @Override 368 public boolean isMergable(Layer other) { 369 return false; 370 } 371 372 @Override 373 public void visitBoundingBox(BoundingXYVisitor v) { 374 for (Note note : noteData.getNotes()) { 375 v.visit(note.getLatLon()); 376 } 377 } 378 379 @Override 380 public Object getInfoComponent() { 381 StringBuilder sb = new StringBuilder(); 382 sb.append(tr("Notes layer")) 383 .append('\n') 384 .append(tr("Total notes:")) 385 .append(' ') 386 .append(noteData.getNotes().size()) 387 .append('\n') 388 .append(tr("Changes need uploading?")) 389 .append(' ') 390 .append(isModified()); 391 return sb.toString(); 392 } 393 394 @Override 395 public Action[] getMenuEntries() { 396 List<Action> actions = new ArrayList<>(); 397 actions.add(LayerListDialog.getInstance().createShowHideLayerAction()); 398 actions.add(LayerListDialog.getInstance().createDeleteLayerAction()); 399 actions.add(new LayerListPopup.InfoAction(this)); 400 actions.add(new LayerSaveAction(this)); 401 actions.add(new LayerSaveAsAction(this)); 402 return actions.toArray(new Action[0]); 403 } 404 405 @Override 406 public void mouseClicked(MouseEvent e) { 407 if (!SwingUtilities.isLeftMouseButton(e)) { 408 return; 409 } 410 Point clickPoint = e.getPoint(); 411 double snapDistance = 10; 412 double minDistance = Double.MAX_VALUE; 413 final int iconHeight = ImageProvider.ImageSizes.SMALLICON.getAdjustedHeight(); 414 Note closestNote = null; 415 for (Note note : noteData.getNotes()) { 416 Point notePoint = MainApplication.getMap().mapView.getPoint(note.getLatLon()); 417 //move the note point to the center of the icon where users are most likely to click when selecting 418 notePoint.setLocation(notePoint.getX(), notePoint.getY() - iconHeight / 2d); 419 double dist = clickPoint.distanceSq(notePoint); 420 if (minDistance > dist && clickPoint.distance(notePoint) < snapDistance) { 421 minDistance = dist; 422 closestNote = note; 423 } 424 } 425 noteData.setSelectedNote(closestNote); 426 } 427 428 @Override 429 public File createAndOpenSaveFileChooser() { 430 return SaveActionBase.createAndOpenSaveFileChooser(tr("Save Note file"), NoteExporter.FILE_FILTER); 431 } 432 433 @Override 434 public AbstractIOTask createUploadTask(ProgressMonitor monitor) { 435 return new UploadNoteLayerTask(this, monitor); 436 } 437 438 @Override 439 public void mousePressed(MouseEvent e) { 440 // Do nothing 441 } 442 443 @Override 444 public void mouseReleased(MouseEvent e) { 445 // Do nothing 446 } 447 448 @Override 449 public void mouseEntered(MouseEvent e) { 450 // Do nothing 451 } 452 453 @Override 454 public void mouseExited(MouseEvent e) { 455 // Do nothing 456 } 457 458 @Override 459 public void noteDataUpdated(NoteData data) { 460 invalidate(); 461 } 462 463 @Override 464 public void selectedNoteChanged(NoteData noteData) { 465 invalidate(); 466 } 467}