001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.layer;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005import static org.openstreetmap.josm.tools.I18n.trn;
006
007import java.awt.Color;
008import java.awt.Dimension;
009import java.awt.Graphics2D;
010import java.awt.Point;
011import java.awt.event.MouseEvent;
012import java.awt.event.MouseListener;
013import java.awt.event.MouseWheelEvent;
014import java.awt.event.MouseWheelListener;
015import java.io.File;
016import java.text.DateFormat;
017import java.util.ArrayList;
018import java.util.Collection;
019import java.util.Collections;
020import java.util.List;
021import java.util.regex.Matcher;
022import java.util.regex.Pattern;
023
024import javax.swing.Action;
025import javax.swing.BorderFactory;
026import javax.swing.Icon;
027import javax.swing.ImageIcon;
028import javax.swing.JEditorPane;
029import javax.swing.JWindow;
030import javax.swing.SwingUtilities;
031import javax.swing.UIManager;
032import javax.swing.plaf.basic.BasicHTML;
033import javax.swing.text.View;
034
035import org.openstreetmap.josm.Main;
036import org.openstreetmap.josm.actions.SaveActionBase;
037import org.openstreetmap.josm.data.Bounds;
038import org.openstreetmap.josm.data.notes.Note;
039import org.openstreetmap.josm.data.notes.Note.State;
040import org.openstreetmap.josm.data.notes.NoteComment;
041import org.openstreetmap.josm.data.osm.NoteData;
042import org.openstreetmap.josm.data.osm.NoteData.NoteDataUpdateListener;
043import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
044import org.openstreetmap.josm.gui.MainApplication;
045import org.openstreetmap.josm.gui.MainFrame;
046import org.openstreetmap.josm.gui.MapView;
047import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
048import org.openstreetmap.josm.gui.dialogs.LayerListPopup;
049import org.openstreetmap.josm.gui.io.AbstractIOTask;
050import org.openstreetmap.josm.gui.io.UploadNoteLayerTask;
051import org.openstreetmap.josm.gui.io.importexport.NoteExporter;
052import org.openstreetmap.josm.gui.progress.ProgressMonitor;
053import org.openstreetmap.josm.gui.widgets.HtmlPanel;
054import org.openstreetmap.josm.io.XmlWriter;
055import org.openstreetmap.josm.spi.preferences.Config;
056import org.openstreetmap.josm.tools.ColorHelper;
057import org.openstreetmap.josm.tools.ImageProvider;
058import org.openstreetmap.josm.tools.Logging;
059import org.openstreetmap.josm.tools.date.DateUtils;
060
061/**
062 * A layer to hold Note objects.
063 * @since 7522
064 */
065public class NoteLayer extends AbstractModifiableLayer implements MouseListener, NoteDataUpdateListener {
066
067    /**
068     * Pattern to detect end of sentences followed by another one, or a link, in western script.
069     * Group 1 (capturing): period, interrogation mark, exclamation mark
070     * Group non capturing: at least one horizontal or vertical whitespace
071     * Group 2 (capturing): a letter (any script), or any punctuation
072     */
073    private static final Pattern SENTENCE_MARKS_WESTERN = Pattern.compile("([\\.\\?\\!])(?:[\\h\\v]+)([\\p{L}\\p{Punct}])");
074
075    /**
076     * Pattern to detect end of sentences followed by another one, or a link, in eastern script.
077     * Group 1 (capturing): ideographic full stop
078     * Group 2 (capturing): a letter (any script), or any punctuation
079     */
080    private static final Pattern SENTENCE_MARKS_EASTERN = Pattern.compile("(\\u3002)([\\p{L}\\p{Punct}])");
081
082    private static final Pattern HTTP_LINK = Pattern.compile("(https?://[^\\s\\(\\)<>]+)");
083    private static final Pattern HTML_LINK = Pattern.compile("<a href=\"[^\"]+\">([^<]+)</a>");
084    private static final Pattern HTML_LINK_MARK = Pattern.compile("<a href=\"([^\"]+)([\\.\\?\\!])\">([^<]+)(?:[\\.\\?\\!])</a>");
085    private static final Pattern SLASH = Pattern.compile("([^/])/([^/])");
086
087    private final NoteData noteData;
088
089    private Note displayedNote;
090    private HtmlPanel displayedPanel;
091    private JWindow displayedWindow;
092
093    /**
094     * Create a new note layer with a set of notes
095     * @param notes A list of notes to show in this layer
096     * @param name The name of the layer. Typically "Notes"
097     */
098    public NoteLayer(Collection<Note> notes, String name) {
099        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 / 2d);
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}