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

Last change on this file since 17318 was 16967, checked in by simon04, 4 years ago

fix #19510 - Add "Zoom to layer" in context menu of layers in the Layers panel

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