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