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

Last change on this file since 14625 was 14153, checked in by Don-vip, 6 years ago

see #15229 - deprecate Main.parent and Main itself

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