source: josm/trunk/src/org/openstreetmap/josm/gui/layer/NoteLayer.java@ 18211

Last change on this file since 18211 was 18211, checked in by Don-vip, 3 years ago

global use of !Utils.isEmpty/isBlank

  • Property svn:eol-style set to native
File size: 18.1 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.gui.layer;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5import static org.openstreetmap.josm.tools.I18n.trn;
6
7import java.awt.Color;
8import java.awt.Dimension;
9import java.awt.Graphics2D;
10import java.awt.Point;
11import java.awt.event.MouseEvent;
12import java.awt.event.MouseListener;
13import java.awt.event.MouseWheelEvent;
14import java.awt.event.MouseWheelListener;
15import java.io.File;
16import java.io.IOException;
17import java.time.format.FormatStyle;
18import java.util.Collection;
19import java.util.Collections;
20import java.util.Objects;
21import java.util.regex.Matcher;
22import java.util.regex.Pattern;
23
24import javax.swing.Action;
25import javax.swing.BorderFactory;
26import javax.swing.Icon;
27import javax.swing.ImageIcon;
28import javax.swing.JEditorPane;
29import javax.swing.JWindow;
30import javax.swing.SwingUtilities;
31import javax.swing.UIManager;
32import javax.swing.plaf.basic.BasicHTML;
33import javax.swing.text.View;
34
35import org.openstreetmap.josm.actions.AutoScaleAction;
36import org.openstreetmap.josm.actions.SaveActionBase;
37import org.openstreetmap.josm.data.Bounds;
38import org.openstreetmap.josm.data.Data;
39import org.openstreetmap.josm.data.notes.Note;
40import org.openstreetmap.josm.data.notes.Note.State;
41import org.openstreetmap.josm.data.notes.NoteComment;
42import org.openstreetmap.josm.data.osm.NoteData;
43import org.openstreetmap.josm.data.osm.NoteData.NoteDataUpdateListener;
44import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
45import org.openstreetmap.josm.gui.MainApplication;
46import org.openstreetmap.josm.gui.MapView;
47import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
48import org.openstreetmap.josm.gui.dialogs.LayerListPopup;
49import org.openstreetmap.josm.gui.io.AbstractIOTask;
50import org.openstreetmap.josm.gui.io.UploadNoteLayerTask;
51import org.openstreetmap.josm.gui.io.importexport.NoteExporter;
52import org.openstreetmap.josm.gui.progress.ProgressMonitor;
53import org.openstreetmap.josm.gui.widgets.HtmlPanel;
54import org.openstreetmap.josm.io.XmlWriter;
55import org.openstreetmap.josm.spi.preferences.Config;
56import org.openstreetmap.josm.tools.ColorHelper;
57import org.openstreetmap.josm.tools.ImageProvider;
58import org.openstreetmap.josm.tools.Logging;
59import org.openstreetmap.josm.tools.Utils;
60import org.openstreetmap.josm.tools.date.DateUtils;
61
62/**
63 * A layer to hold Note objects.
64 * @since 7522
65 */
66public class NoteLayer extends AbstractModifiableLayer implements MouseListener, NoteDataUpdateListener {
67
68 /**
69 * Pattern to detect end of sentences followed by another one, or a link, in western script.
70 * Group 1 (capturing): period, interrogation mark, exclamation mark
71 * Group non capturing: at least one horizontal or vertical whitespace
72 * Group 2 (capturing): a letter (any script), or any punctuation
73 */
74 private static final Pattern SENTENCE_MARKS_WESTERN = Pattern.compile("([\\.\\?\\!])(?:[\\h\\v]+)([\\p{L}\\p{Punct}])");
75
76 /**
77 * Pattern to detect end of sentences followed by another one, or a link, in eastern script.
78 * Group 1 (capturing): ideographic full stop
79 * Group 2 (capturing): a letter (any script), or any punctuation
80 */
81 private static final Pattern SENTENCE_MARKS_EASTERN = Pattern.compile("(\\u3002)([\\p{L}\\p{Punct}])");
82
83 private static final Pattern HTTP_LINK = Pattern.compile("(https?://[^\\s\\(\\)<>]+)");
84 private static final Pattern HTML_LINK = Pattern.compile("<a href=\"[^\"]+\">([^<]+)</a>");
85 private static final Pattern HTML_LINK_MARK = Pattern.compile("<a href=\"([^\"]+)([\\.\\?\\!])\">([^<]+)(?:[\\.\\?\\!])</a>");
86 private static final Pattern SLASH = Pattern.compile("([^/])/([^/])");
87
88 private final NoteData noteData;
89
90 private Note displayedNote;
91 private HtmlPanel displayedPanel;
92 private JWindow displayedWindow;
93
94 /**
95 * Create a new note layer with a set of notes
96 * @param notes A list of notes to show in this layer
97 * @param name The name of the layer. Typically "Notes"
98 */
99 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 = "&lt;Anonymous&gt;";
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("&#xA;", "<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}
Note: See TracBrowser for help on using the repository browser.