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

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

fix #8039, fix #10456: final fixes for the read-only/locked layers:

  • rename "read-only" to "locked" (in XML and Java classes/interfaces)
  • add a new download policy (true/never) to allow private layers forbidding only to download data, but allowing everything else

This leads to:

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