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.Objects;
022import java.util.regex.Matcher;
023import java.util.regex.Pattern;
024
025import javax.swing.Action;
026import javax.swing.BorderFactory;
027import javax.swing.Icon;
028import javax.swing.ImageIcon;
029import javax.swing.JEditorPane;
030import javax.swing.JWindow;
031import javax.swing.SwingUtilities;
032import javax.swing.UIManager;
033import javax.swing.plaf.basic.BasicHTML;
034import javax.swing.text.View;
035
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.MapView;
046import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
047import org.openstreetmap.josm.gui.dialogs.LayerListPopup;
048import org.openstreetmap.josm.gui.io.AbstractIOTask;
049import org.openstreetmap.josm.gui.io.UploadNoteLayerTask;
050import org.openstreetmap.josm.gui.io.importexport.NoteExporter;
051import org.openstreetmap.josm.gui.progress.ProgressMonitor;
052import org.openstreetmap.josm.gui.widgets.HtmlPanel;
053import org.openstreetmap.josm.io.XmlWriter;
054import org.openstreetmap.josm.spi.preferences.Config;
055import org.openstreetmap.josm.tools.ColorHelper;
056import org.openstreetmap.josm.tools.ImageProvider;
057import org.openstreetmap.josm.tools.Logging;
058import org.openstreetmap.josm.tools.date.DateUtils;
059
060/**
061 * A layer to hold Note objects.
062 * @since 7522
063 */
064public class NoteLayer extends AbstractModifiableLayer implements MouseListener, NoteDataUpdateListener {
065
066    /**
067     * Pattern to detect end of sentences followed by another one, or a link, in western script.
068     * Group 1 (capturing): period, interrogation mark, exclamation mark
069     * Group non capturing: at least one horizontal or vertical whitespace
070     * Group 2 (capturing): a letter (any script), or any punctuation
071     */
072    private static final Pattern SENTENCE_MARKS_WESTERN = Pattern.compile("([\\.\\?\\!])(?:[\\h\\v]+)([\\p{L}\\p{Punct}])");
073
074    /**
075     * Pattern to detect end of sentences followed by another one, or a link, in eastern script.
076     * Group 1 (capturing): ideographic full stop
077     * Group 2 (capturing): a letter (any script), or any punctuation
078     */
079    private static final Pattern SENTENCE_MARKS_EASTERN = Pattern.compile("(\\u3002)([\\p{L}\\p{Punct}])");
080
081    private static final Pattern HTTP_LINK = Pattern.compile("(https?://[^\\s\\(\\)<>]+)");
082    private static final Pattern HTML_LINK = Pattern.compile("<a href=\"[^\"]+\">([^<]+)</a>");
083    private static final Pattern HTML_LINK_MARK = Pattern.compile("<a href=\"([^\"]+)([\\.\\?\\!])\">([^<]+)(?:[\\.\\?\\!])</a>");
084    private static final Pattern SLASH = Pattern.compile("([^/])/([^/])");
085
086    private final NoteData noteData;
087
088    private Note displayedNote;
089    private HtmlPanel displayedPanel;
090    private JWindow displayedWindow;
091
092    /**
093     * Create a new note layer with a set of notes
094     * @param notes A list of notes to show in this layer
095     * @param name The name of the layer. Typically "Notes"
096     */
097    public NoteLayer(Collection<Note> notes, String name) {
098        this(new NoteData(notes), name);
099    }
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
477    @Override
478    public String getChangesetSourceTag() {
479        return "Notes";
480    }
481}