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