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