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

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

see #15550 - better (?) resizing of note tooltips

  • Property svn:eol-style set to native
File size: 15.0 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.Pattern;
22
23import javax.swing.Action;
24import javax.swing.BorderFactory;
25import javax.swing.Icon;
26import javax.swing.ImageIcon;
27import javax.swing.JEditorPane;
28import javax.swing.JWindow;
29import javax.swing.SwingUtilities;
30import javax.swing.UIManager;
31import javax.swing.plaf.basic.BasicHTML;
32import javax.swing.text.View;
33
34import org.openstreetmap.josm.Main;
35import org.openstreetmap.josm.actions.SaveActionBase;
36import org.openstreetmap.josm.data.Bounds;
37import org.openstreetmap.josm.data.notes.Note;
38import org.openstreetmap.josm.data.notes.Note.State;
39import org.openstreetmap.josm.data.notes.NoteComment;
40import org.openstreetmap.josm.data.osm.NoteData;
41import org.openstreetmap.josm.data.osm.NoteData.NoteDataUpdateListener;
42import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
43import org.openstreetmap.josm.gui.MainApplication;
44import org.openstreetmap.josm.gui.MainFrame;
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 final NoteData noteData;
82
83 private Note displayedNote;
84 private HtmlPanel displayedPanel;
85 private JWindow displayedWindow;
86
87 /**
88 * Create a new note layer with a set of notes
89 * @param notes A list of notes to show in this layer
90 * @param name The name of the layer. Typically "Notes"
91 */
92 public NoteLayer(Collection<Note> notes, String name) {
93 super(name);
94 noteData = new NoteData(notes);
95 noteData.addNoteDataUpdateListener(this);
96 }
97
98 /** Convenience constructor that creates a layer with an empty note list */
99 public NoteLayer() {
100 this(Collections.<Note>emptySet(), tr("Notes"));
101 }
102
103 @Override
104 public void hookUpMapView() {
105 MainApplication.getMap().mapView.addMouseListener(this);
106 }
107
108 @Override
109 public synchronized void destroy() {
110 MainApplication.getMap().mapView.removeMouseListener(this);
111 noteData.removeNoteDataUpdateListener(this);
112 hideNoteWindow();
113 super.destroy();
114 }
115
116 /**
117 * Returns the note data store being used by this layer
118 * @return noteData containing layer notes
119 */
120 public NoteData getNoteData() {
121 return noteData;
122 }
123
124 @Override
125 public boolean isModified() {
126 return noteData.isModified();
127 }
128
129 @Override
130 public boolean isUploadable() {
131 return true;
132 }
133
134 @Override
135 public boolean requiresUploadToServer() {
136 return isModified();
137 }
138
139 @Override
140 public boolean isSavable() {
141 return true;
142 }
143
144 @Override
145 public boolean requiresSaveToFile() {
146 return getAssociatedFile() != null && isModified();
147 }
148
149 @Override
150 public void paint(Graphics2D g, MapView mv, Bounds box) {
151 final int iconHeight = ImageProvider.ImageSizes.SMALLICON.getAdjustedHeight();
152 final int iconWidth = ImageProvider.ImageSizes.SMALLICON.getAdjustedWidth();
153
154 for (Note note : noteData.getNotes()) {
155 Point p = mv.getPoint(note.getLatLon());
156
157 ImageIcon icon;
158 if (note.getId() < 0) {
159 icon = ImageProvider.get("dialogs/notes", "note_new", ImageProvider.ImageSizes.SMALLICON);
160 } else if (note.getState() == State.CLOSED) {
161 icon = ImageProvider.get("dialogs/notes", "note_closed", ImageProvider.ImageSizes.SMALLICON);
162 } else {
163 icon = ImageProvider.get("dialogs/notes", "note_open", ImageProvider.ImageSizes.SMALLICON);
164 }
165 int width = icon.getIconWidth();
166 int height = icon.getIconHeight();
167 g.drawImage(icon.getImage(), p.x - (width / 2), p.y - height, MainApplication.getMap().mapView);
168 }
169 Note selectedNote = noteData.getSelectedNote();
170 if (selectedNote != null) {
171 paintSelectedNote(g, mv, iconHeight, iconWidth, selectedNote);
172 } else {
173 hideNoteWindow();
174 }
175 }
176
177 private void hideNoteWindow() {
178 if (displayedWindow != null) {
179 displayedWindow.setVisible(false);
180 for (MouseWheelListener listener : displayedWindow.getMouseWheelListeners()) {
181 displayedWindow.removeMouseWheelListener(listener);
182 }
183 displayedWindow.dispose();
184 displayedWindow = null;
185 displayedPanel = null;
186 displayedNote = null;
187 }
188 }
189
190 private void paintSelectedNote(Graphics2D g, MapView mv, final int iconHeight, final int iconWidth, Note selectedNote) {
191 Point p = mv.getPoint(selectedNote.getLatLon());
192
193 g.setColor(ColorHelper.html2color(Config.getPref().get("color.selected")));
194 g.drawRect(p.x - (iconWidth / 2), p.y - iconHeight, iconWidth - 1, iconHeight - 1);
195
196 if (displayedNote != null && !displayedNote.equals(selectedNote)) {
197 hideNoteWindow();
198 }
199
200 Point screenloc = mv.getLocationOnScreen();
201 int tx = screenloc.x + p.x + (iconWidth / 2) + 5;
202 int ty = screenloc.y + p.y - iconHeight - 1;
203
204 String text = getNoteToolTip(selectedNote);
205
206 if (displayedWindow == null) {
207 displayedPanel = new HtmlPanel(text);
208 displayedPanel.setBackground(UIManager.getColor("ToolTip.background"));
209 displayedPanel.setForeground(UIManager.getColor("ToolTip.foreground"));
210 displayedPanel.setFont(UIManager.getFont("ToolTip.font"));
211 displayedPanel.setBorder(BorderFactory.createLineBorder(Color.black));
212 displayedPanel.enableClickableHyperlinks();
213 fixPanelSize(mv, text);
214 displayedWindow = new JWindow((MainFrame) Main.parent);
215 displayedWindow.setAutoRequestFocus(false);
216 displayedWindow.add(displayedPanel);
217 // Forward mouse wheel scroll event to MapMover
218 displayedWindow.addMouseWheelListener(e -> mv.getMapMover().mouseWheelMoved(
219 (MouseWheelEvent) SwingUtilities.convertMouseEvent(displayedWindow, e, mv)));
220 } else {
221 displayedPanel.setText(text);
222 fixPanelSize(mv, text);
223 }
224
225 displayedWindow.pack();
226 displayedWindow.setLocation(tx, ty);
227 displayedWindow.setVisible(mv.contains(p));
228 displayedNote = selectedNote;
229 }
230
231 private void fixPanelSize(MapView mv, String text) {
232 int maxWidth = mv.getWidth() * 2/3;
233 JEditorPane pane = displayedPanel.getEditorPane();
234 if (pane.getPreferredSize().width > maxWidth && Config.getPref().getBoolean("note.text.break-on-sentence-mark", false)) {
235 // To make sure long notes are displayed correctly
236 displayedPanel.setText(insertLineBreaks(text));
237 }
238 // If still too large, enforce maximum size
239 Dimension d = pane.getPreferredSize();
240 if (d.width > maxWidth) {
241 View v = (View) pane.getClientProperty(BasicHTML.propertyKey);
242 if (v == null) {
243 BasicHTML.updateRenderer(pane, text);
244 v = (View) pane.getClientProperty(BasicHTML.propertyKey);
245 }
246 if (v != null) {
247 v.setSize(maxWidth, 0);
248 int w = (int) Math.ceil(v.getPreferredSpan(View.X_AXIS)) + 30;
249 int h = (int) Math.ceil(v.getPreferredSpan(View.Y_AXIS)) + 10;
250 pane.setPreferredSize(new Dimension(w, h));
251 }
252 }
253 }
254
255 /**
256 * Inserts HTML line breaks ({@code <br>} at the end of each sentence mark
257 * (period, interrogation mark, exclamation mark, ideographic full stop).
258 * @param longText a long text that does not fit on a single line without exceeding half of the map view
259 * @return text with line breaks
260 */
261 static String insertLineBreaks(String longText) {
262 return SENTENCE_MARKS_WESTERN.matcher(SENTENCE_MARKS_EASTERN.matcher(longText).replaceAll("$1<br>$2")).replaceAll("$1<br>$2");
263 }
264
265 /**
266 * Returns the HTML-formatted tooltip text for the given note.
267 * @param note note to display
268 * @return the HTML-formatted tooltip text for the given note
269 * @since 13111
270 */
271 public static String getNoteToolTip(Note note) {
272 StringBuilder sb = new StringBuilder("<html>");
273 sb.append(tr("Note"))
274 .append(' ').append(note.getId());
275 for (NoteComment comment : note.getComments()) {
276 String commentText = comment.getText();
277 //closing a note creates an empty comment that we don't want to show
278 if (commentText != null && !commentText.trim().isEmpty()) {
279 sb.append("<hr/>");
280 String userName = XmlWriter.encode(comment.getUser().getName());
281 if (userName == null || userName.trim().isEmpty()) {
282 userName = "&lt;Anonymous&gt;";
283 }
284 sb.append(userName)
285 .append(" on ")
286 .append(DateUtils.getDateFormat(DateFormat.MEDIUM).format(comment.getCommentTimestamp()))
287 .append(":<br>");
288 String htmlText = XmlWriter.encode(comment.getText(), true);
289 // encode method leaves us with entity instead of \n
290 htmlText = htmlText.replace("&#xA;", "<br>");
291 // convert URLs to proper HTML links
292 htmlText = htmlText.replaceAll("(https?://[^\\s\\(\\)<>]+)", "<a href=\"$1\">$1</a>");
293 sb.append(htmlText);
294 }
295 }
296 sb.append("</html>");
297 String result = sb.toString();
298 Logging.debug(result);
299 return result;
300 }
301
302 @Override
303 public Icon getIcon() {
304 return ImageProvider.get("dialogs/notes", "note_open", ImageProvider.ImageSizes.SMALLICON);
305 }
306
307 @Override
308 public String getToolTipText() {
309 return trn("{0} note", "{0} notes", noteData.getNotes().size(), noteData.getNotes().size());
310 }
311
312 @Override
313 public void mergeFrom(Layer from) {
314 throw new UnsupportedOperationException("Notes layer does not support merging yet");
315 }
316
317 @Override
318 public boolean isMergable(Layer other) {
319 return false;
320 }
321
322 @Override
323 public void visitBoundingBox(BoundingXYVisitor v) {
324 for (Note note : noteData.getNotes()) {
325 v.visit(note.getLatLon());
326 }
327 }
328
329 @Override
330 public Object getInfoComponent() {
331 StringBuilder sb = new StringBuilder();
332 sb.append(tr("Notes layer"))
333 .append('\n')
334 .append(tr("Total notes:"))
335 .append(' ')
336 .append(noteData.getNotes().size())
337 .append('\n')
338 .append(tr("Changes need uploading?"))
339 .append(' ')
340 .append(isModified());
341 return sb.toString();
342 }
343
344 @Override
345 public Action[] getMenuEntries() {
346 List<Action> actions = new ArrayList<>();
347 actions.add(LayerListDialog.getInstance().createShowHideLayerAction());
348 actions.add(LayerListDialog.getInstance().createDeleteLayerAction());
349 actions.add(new LayerListPopup.InfoAction(this));
350 actions.add(new LayerSaveAction(this));
351 actions.add(new LayerSaveAsAction(this));
352 return actions.toArray(new Action[actions.size()]);
353 }
354
355 @Override
356 public void mouseClicked(MouseEvent e) {
357 if (!SwingUtilities.isLeftMouseButton(e)) {
358 return;
359 }
360 Point clickPoint = e.getPoint();
361 double snapDistance = 10;
362 double minDistance = Double.MAX_VALUE;
363 final int iconHeight = ImageProvider.ImageSizes.SMALLICON.getAdjustedHeight();
364 Note closestNote = null;
365 for (Note note : noteData.getNotes()) {
366 Point notePoint = MainApplication.getMap().mapView.getPoint(note.getLatLon());
367 //move the note point to the center of the icon where users are most likely to click when selecting
368 notePoint.setLocation(notePoint.getX(), notePoint.getY() - iconHeight / 2);
369 double dist = clickPoint.distanceSq(notePoint);
370 if (minDistance > dist && clickPoint.distance(notePoint) < snapDistance) {
371 minDistance = dist;
372 closestNote = note;
373 }
374 }
375 noteData.setSelectedNote(closestNote);
376 }
377
378 @Override
379 public File createAndOpenSaveFileChooser() {
380 return SaveActionBase.createAndOpenSaveFileChooser(tr("Save Note file"), NoteExporter.FILE_FILTER);
381 }
382
383 @Override
384 public AbstractIOTask createUploadTask(ProgressMonitor monitor) {
385 return new UploadNoteLayerTask(this, monitor);
386 }
387
388 @Override
389 public void mousePressed(MouseEvent e) {
390 // Do nothing
391 }
392
393 @Override
394 public void mouseReleased(MouseEvent e) {
395 // Do nothing
396 }
397
398 @Override
399 public void mouseEntered(MouseEvent e) {
400 // Do nothing
401 }
402
403 @Override
404 public void mouseExited(MouseEvent e) {
405 // Do nothing
406 }
407
408 @Override
409 public void noteDataUpdated(NoteData data) {
410 invalidate();
411 }
412
413 @Override
414 public void selectedNoteChanged(NoteData noteData) {
415 invalidate();
416 }
417}
Note: See TracBrowser for help on using the repository browser.