001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.layer.geoimage; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.awt.BorderLayout; 007import java.awt.Component; 008import java.awt.Dimension; 009import java.awt.GridBagConstraints; 010import java.awt.GridBagLayout; 011import java.awt.event.ActionEvent; 012import java.awt.event.KeyEvent; 013import java.awt.event.WindowEvent; 014import java.text.DateFormat; 015import java.text.SimpleDateFormat; 016import java.util.Optional; 017 018import javax.swing.Box; 019import javax.swing.JButton; 020import javax.swing.JLabel; 021import javax.swing.JOptionPane; 022import javax.swing.JPanel; 023import javax.swing.JToggleButton; 024import javax.swing.SwingConstants; 025 026import org.openstreetmap.josm.actions.JosmAction; 027import org.openstreetmap.josm.data.ImageData; 028import org.openstreetmap.josm.data.ImageData.ImageDataUpdateListener; 029import org.openstreetmap.josm.gui.ExtendedDialog; 030import org.openstreetmap.josm.gui.MainApplication; 031import org.openstreetmap.josm.gui.datatransfer.ClipboardUtils; 032import org.openstreetmap.josm.gui.dialogs.DialogsPanel.Action; 033import org.openstreetmap.josm.gui.dialogs.ToggleDialog; 034import org.openstreetmap.josm.gui.layer.Layer; 035import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent; 036import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener; 037import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent; 038import org.openstreetmap.josm.gui.layer.LayerManager.LayerRemoveEvent; 039import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeEvent; 040import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeListener; 041import org.openstreetmap.josm.tools.ImageProvider; 042import org.openstreetmap.josm.tools.Logging; 043import org.openstreetmap.josm.tools.Shortcut; 044import org.openstreetmap.josm.tools.Utils; 045import org.openstreetmap.josm.tools.date.DateUtils; 046 047/** 048 * Dialog to view and manipulate geo-tagged images from a {@link GeoImageLayer}. 049 */ 050public final class ImageViewerDialog extends ToggleDialog implements LayerChangeListener, ActiveLayerChangeListener, ImageDataUpdateListener { 051 052 private final ImageZoomAction imageZoomAction = new ImageZoomAction(); 053 private final ImageCenterViewAction imageCenterViewAction = new ImageCenterViewAction(); 054 private final ImageNextAction imageNextAction = new ImageNextAction(); 055 private final ImageRemoveAction imageRemoveAction = new ImageRemoveAction(); 056 private final ImageRemoveFromDiskAction imageRemoveFromDiskAction = new ImageRemoveFromDiskAction(); 057 private final ImagePreviousAction imagePreviousAction = new ImagePreviousAction(); 058 private final ImageCollapseAction imageCollapseAction = new ImageCollapseAction(); 059 private final ImageFirstAction imageFirstAction = new ImageFirstAction(); 060 private final ImageLastAction imageLastAction = new ImageLastAction(); 061 private final ImageCopyPathAction imageCopyPathAction = new ImageCopyPathAction(); 062 063 private final ImageDisplay imgDisplay = new ImageDisplay(); 064 private boolean centerView; 065 066 // Only one instance of that class is present at one time 067 private static volatile ImageViewerDialog dialog; 068 069 private boolean collapseButtonClicked; 070 071 static void createInstance() { 072 if (dialog != null) 073 throw new IllegalStateException("ImageViewerDialog instance was already created"); 074 dialog = new ImageViewerDialog(); 075 } 076 077 /** 078 * Replies the unique instance of this dialog 079 * @return the unique instance 080 */ 081 public static ImageViewerDialog getInstance() { 082 if (dialog == null) 083 throw new AssertionError("a new instance needs to be created first"); 084 return dialog; 085 } 086 087 private JButton btnLast; 088 private JButton btnNext; 089 private JButton btnPrevious; 090 private JButton btnFirst; 091 private JButton btnCollapse; 092 private JButton btnDelete; 093 private JButton btnCopyPath; 094 private JButton btnDeleteFromDisk; 095 private JToggleButton tbCentre; 096 097 private ImageViewerDialog() { 098 super(tr("Geotagged Images"), "geoimage", tr("Display geotagged images"), Shortcut.registerShortcut("tools:geotagged", 099 tr("Tool: {0}", tr("Display geotagged images")), KeyEvent.VK_Y, Shortcut.DIRECT), 200); 100 build(); 101 MainApplication.getLayerManager().addActiveLayerChangeListener(this); 102 MainApplication.getLayerManager().addLayerChangeListener(this); 103 for (Layer l: MainApplication.getLayerManager().getLayers()) { 104 registerOnLayer(l); 105 } 106 } 107 108 private static JButton createNavigationButton(JosmAction action, Dimension buttonDim) { 109 JButton btn = new JButton(action); 110 btn.setPreferredSize(buttonDim); 111 btn.setEnabled(false); 112 return btn; 113 } 114 115 private void build() { 116 JPanel content = new JPanel(new BorderLayout()); 117 118 content.add(imgDisplay, BorderLayout.CENTER); 119 120 Dimension buttonDim = new Dimension(26, 26); 121 122 btnFirst = createNavigationButton(imageFirstAction, buttonDim); 123 btnPrevious = createNavigationButton(imagePreviousAction, buttonDim); 124 125 btnDelete = new JButton(imageRemoveAction); 126 btnDelete.setPreferredSize(buttonDim); 127 128 btnDeleteFromDisk = new JButton(imageRemoveFromDiskAction); 129 btnDeleteFromDisk.setPreferredSize(buttonDim); 130 131 btnCopyPath = new JButton(imageCopyPathAction); 132 btnCopyPath.setPreferredSize(buttonDim); 133 134 btnNext = createNavigationButton(imageNextAction, buttonDim); 135 btnLast = createNavigationButton(imageLastAction, buttonDim); 136 137 tbCentre = new JToggleButton(imageCenterViewAction); 138 tbCentre.setPreferredSize(buttonDim); 139 140 JButton btnZoomBestFit = new JButton(imageZoomAction); 141 btnZoomBestFit.setPreferredSize(buttonDim); 142 143 btnCollapse = new JButton(imageCollapseAction); 144 btnCollapse.setPreferredSize(new Dimension(20, 20)); 145 btnCollapse.setAlignmentY(Component.TOP_ALIGNMENT); 146 147 JPanel buttons = new JPanel(); 148 buttons.add(btnFirst); 149 buttons.add(btnPrevious); 150 buttons.add(btnNext); 151 buttons.add(btnLast); 152 buttons.add(Box.createRigidArea(new Dimension(7, 0))); 153 buttons.add(tbCentre); 154 buttons.add(btnZoomBestFit); 155 buttons.add(Box.createRigidArea(new Dimension(7, 0))); 156 buttons.add(btnDelete); 157 buttons.add(btnDeleteFromDisk); 158 buttons.add(Box.createRigidArea(new Dimension(7, 0))); 159 buttons.add(btnCopyPath); 160 161 JPanel bottomPane = new JPanel(new GridBagLayout()); 162 GridBagConstraints gc = new GridBagConstraints(); 163 gc.gridx = 0; 164 gc.gridy = 0; 165 gc.anchor = GridBagConstraints.CENTER; 166 gc.weightx = 1; 167 bottomPane.add(buttons, gc); 168 169 gc.gridx = 1; 170 gc.gridy = 0; 171 gc.anchor = GridBagConstraints.PAGE_END; 172 gc.weightx = 0; 173 bottomPane.add(btnCollapse, gc); 174 175 content.add(bottomPane, BorderLayout.SOUTH); 176 177 createLayout(content, false, null); 178 } 179 180 @Override 181 public void destroy() { 182 MainApplication.getLayerManager().removeActiveLayerChangeListener(this); 183 MainApplication.getLayerManager().removeLayerChangeListener(this); 184 // Manually destroy actions until JButtons are replaced by standard SideButtons 185 imageFirstAction.destroy(); 186 imageLastAction.destroy(); 187 imagePreviousAction.destroy(); 188 imageNextAction.destroy(); 189 imageCenterViewAction.destroy(); 190 imageCollapseAction.destroy(); 191 imageCopyPathAction.destroy(); 192 imageRemoveAction.destroy(); 193 imageRemoveFromDiskAction.destroy(); 194 imageZoomAction.destroy(); 195 super.destroy(); 196 dialog = null; 197 } 198 199 private class ImageNextAction extends JosmAction { 200 ImageNextAction() { 201 super(null, new ImageProvider("dialogs", "next"), tr("Next"), Shortcut.registerShortcut( 202 "geoimage:next", tr("Geoimage: {0}", tr("Show next Image")), KeyEvent.VK_PAGE_DOWN, Shortcut.DIRECT), 203 false, null, false); 204 } 205 206 @Override 207 public void actionPerformed(ActionEvent e) { 208 if (currentData != null) { 209 currentData.selectNextImage(); 210 } 211 } 212 } 213 214 private class ImagePreviousAction extends JosmAction { 215 ImagePreviousAction() { 216 super(null, new ImageProvider("dialogs", "previous"), tr("Previous"), Shortcut.registerShortcut( 217 "geoimage:previous", tr("Geoimage: {0}", tr("Show previous Image")), KeyEvent.VK_PAGE_UP, Shortcut.DIRECT), 218 false, null, false); 219 } 220 221 @Override 222 public void actionPerformed(ActionEvent e) { 223 if (currentData != null) { 224 currentData.selectPreviousImage(); 225 } 226 } 227 } 228 229 private class ImageFirstAction extends JosmAction { 230 ImageFirstAction() { 231 super(null, new ImageProvider("dialogs", "first"), tr("First"), Shortcut.registerShortcut( 232 "geoimage:first", tr("Geoimage: {0}", tr("Show first Image")), KeyEvent.VK_HOME, Shortcut.DIRECT), 233 false, null, false); 234 } 235 236 @Override 237 public void actionPerformed(ActionEvent e) { 238 if (currentData != null) { 239 currentData.selectFirstImage(); 240 } 241 } 242 } 243 244 private class ImageLastAction extends JosmAction { 245 ImageLastAction() { 246 super(null, new ImageProvider("dialogs", "last"), tr("Last"), Shortcut.registerShortcut( 247 "geoimage:last", tr("Geoimage: {0}", tr("Show last Image")), KeyEvent.VK_END, Shortcut.DIRECT), 248 false, null, false); 249 } 250 251 @Override 252 public void actionPerformed(ActionEvent e) { 253 if (currentData != null) { 254 currentData.selectLastImage(); 255 } 256 } 257 } 258 259 private class ImageCenterViewAction extends JosmAction { 260 ImageCenterViewAction() { 261 super(null, new ImageProvider("dialogs", "centreview"), tr("Center view"), null, 262 false, null, false); 263 } 264 265 @Override 266 public void actionPerformed(ActionEvent e) { 267 final JToggleButton button = (JToggleButton) e.getSource(); 268 centerView = button.isEnabled() && button.isSelected(); 269 if (centerView && currentEntry != null && currentEntry.getPos() != null) { 270 MainApplication.getMap().mapView.zoomTo(currentEntry.getPos()); 271 } 272 } 273 } 274 275 private class ImageZoomAction extends JosmAction { 276 ImageZoomAction() { 277 super(null, new ImageProvider("dialogs", "zoom-best-fit"), tr("Zoom best fit and 1:1"), null, 278 false, null, false); 279 } 280 281 @Override 282 public void actionPerformed(ActionEvent e) { 283 imgDisplay.zoomBestFitOrOne(); 284 } 285 } 286 287 private class ImageRemoveAction extends JosmAction { 288 ImageRemoveAction() { 289 super(null, new ImageProvider("dialogs", "delete"), tr("Remove photo from layer"), Shortcut.registerShortcut( 290 "geoimage:deleteimagefromlayer", tr("Geoimage: {0}", tr("Remove photo from layer")), KeyEvent.VK_DELETE, Shortcut.SHIFT), 291 false, null, false); 292 } 293 294 @Override 295 public void actionPerformed(ActionEvent e) { 296 if (currentData != null) { 297 currentData.removeSelectedImage(); 298 } 299 } 300 } 301 302 private class ImageRemoveFromDiskAction extends JosmAction { 303 ImageRemoveFromDiskAction() { 304 super(null, new ImageProvider("dialogs", "geoimage/deletefromdisk"), tr("Delete image file from disk"), 305 Shortcut.registerShortcut( 306 "geoimage:deletefilefromdisk", tr("Geoimage: {0}", tr("Delete File from disk")), KeyEvent.VK_DELETE, Shortcut.CTRL_SHIFT), 307 false, null, false); 308 } 309 310 @Override 311 public void actionPerformed(ActionEvent e) { 312 if (currentData != null && currentData.getSelectedImage() != null) { 313 ImageEntry toDelete = currentData.getSelectedImage(); 314 315 int result = new ExtendedDialog( 316 MainApplication.getMainFrame(), 317 tr("Delete image file from disk"), 318 tr("Cancel"), tr("Delete")) 319 .setButtonIcons("cancel", "dialogs/delete") 320 .setContent(new JLabel("<html><h3>" + tr("Delete the file {0} from disk?", toDelete.getFile().getName()) 321 + "<p>" + tr("The image file will be permanently lost!") + "</h3></html>", 322 ImageProvider.get("dialogs/geoimage/deletefromdisk"), SwingConstants.LEFT)) 323 .toggleEnable("geoimage.deleteimagefromdisk") 324 .setCancelButton(1) 325 .setDefaultButton(2) 326 .showDialog() 327 .getValue(); 328 329 if (result == 2) { 330 currentData.removeSelectedImage(); 331 332 if (Utils.deleteFile(toDelete.getFile())) { 333 Logging.info("File " + toDelete.getFile() + " deleted."); 334 } else { 335 JOptionPane.showMessageDialog( 336 MainApplication.getMainFrame(), 337 tr("Image file could not be deleted."), 338 tr("Error"), 339 JOptionPane.ERROR_MESSAGE 340 ); 341 } 342 } 343 } 344 } 345 } 346 347 private class ImageCopyPathAction extends JosmAction { 348 ImageCopyPathAction() { 349 super(null, new ImageProvider("copy"), tr("Copy image path"), Shortcut.registerShortcut( 350 "geoimage:copypath", tr("Geoimage: {0}", tr("Copy image path")), KeyEvent.VK_C, Shortcut.ALT_CTRL_SHIFT), 351 false, null, false); 352 } 353 354 @Override 355 public void actionPerformed(ActionEvent e) { 356 if (currentData != null) { 357 ClipboardUtils.copyString(currentData.getSelectedImage().getFile().toString()); 358 } 359 } 360 } 361 362 private class ImageCollapseAction extends JosmAction { 363 ImageCollapseAction() { 364 super(null, new ImageProvider("dialogs", "collapse"), tr("Move dialog to the side pane"), null, 365 false, null, false); 366 } 367 368 @Override 369 public void actionPerformed(ActionEvent e) { 370 collapseButtonClicked = true; 371 detachedDialog.getToolkit().getSystemEventQueue().postEvent(new WindowEvent(detachedDialog, WindowEvent.WINDOW_CLOSING)); 372 } 373 } 374 375 /** 376 * Displays image for the given data. 377 * @param data geo image data 378 * @param entry image entry 379 */ 380 public static void showImage(ImageData data, ImageEntry entry) { 381 getInstance().displayImage(data, entry); 382 } 383 384 /** 385 * Enables (or disables) the "Previous" button. 386 * @param value {@code true} to enable the button, {@code false} otherwise 387 */ 388 public void setPreviousEnabled(boolean value) { 389 btnFirst.setEnabled(value); 390 btnPrevious.setEnabled(value); 391 } 392 393 /** 394 * Enables (or disables) the "Next" button. 395 * @param value {@code true} to enable the button, {@code false} otherwise 396 */ 397 public void setNextEnabled(boolean value) { 398 btnNext.setEnabled(value); 399 btnLast.setEnabled(value); 400 } 401 402 /** 403 * Enables (or disables) the "Center view" button. 404 * @param value {@code true} to enable the button, {@code false} otherwise 405 * @return the old enabled value. Can be used to restore the original enable state 406 */ 407 public static synchronized boolean setCentreEnabled(boolean value) { 408 final ImageViewerDialog instance = getInstance(); 409 final boolean wasEnabled = instance.tbCentre.isEnabled(); 410 instance.tbCentre.setEnabled(value); 411 instance.tbCentre.getAction().actionPerformed(new ActionEvent(instance.tbCentre, 0, null)); 412 return wasEnabled; 413 } 414 415 private transient ImageData currentData; 416 private transient ImageEntry currentEntry; 417 418 /** 419 * Displays image for the given layer. 420 * @param data the image data 421 * @param entry image entry 422 */ 423 public void displayImage(ImageData data, ImageEntry entry) { 424 boolean imageChanged; 425 426 synchronized (this) { 427 // TODO: pop up image dialog but don't load image again 428 429 imageChanged = currentEntry != entry; 430 431 if (centerView && entry != null && MainApplication.isDisplayingMapView() && entry.getPos() != null) { 432 MainApplication.getMap().mapView.zoomTo(entry.getPos()); 433 } 434 435 currentData = data; 436 currentEntry = entry; 437 } 438 439 if (entry != null) { 440 setNextEnabled(data.hasNextImage()); 441 setPreviousEnabled(data.hasPreviousImage()); 442 btnDelete.setEnabled(true); 443 btnDeleteFromDisk.setEnabled(true); 444 btnCopyPath.setEnabled(true); 445 446 if (imageChanged) { 447 // Set only if the image is new to preserve zoom and position if the same image is redisplayed 448 // (e.g. to update the OSD). 449 imgDisplay.setImage(entry); 450 } 451 setTitle(tr("Geotagged Images") + (entry.getFile() != null ? " - " + entry.getFile().getName() : "")); 452 StringBuilder osd = new StringBuilder(entry.getFile() != null ? entry.getFile().getName() : ""); 453 if (entry.getElevation() != null) { 454 osd.append(tr("\nAltitude: {0} m", Math.round(entry.getElevation()))); 455 } 456 if (entry.getSpeed() != null) { 457 osd.append(tr("\nSpeed: {0} km/h", Math.round(entry.getSpeed()))); 458 } 459 if (entry.getExifImgDir() != null) { 460 osd.append(tr("\nDirection {0}\u00b0", Math.round(entry.getExifImgDir()))); 461 } 462 DateFormat dtf = DateUtils.getDateTimeFormat(DateFormat.SHORT, DateFormat.MEDIUM); 463 // Make sure date/time format includes milliseconds 464 if (dtf instanceof SimpleDateFormat) { 465 String pattern = ((SimpleDateFormat) dtf).toPattern(); 466 if (!pattern.contains(".SSS")) { 467 dtf = new SimpleDateFormat(pattern.replace(":ss", ":ss.SSS")); 468 } 469 } 470 if (entry.hasExifTime()) { 471 osd.append(tr("\nEXIF time: {0}", dtf.format(entry.getExifTime()))); 472 } 473 if (entry.hasGpsTime()) { 474 osd.append(tr("\nGPS time: {0}", dtf.format(entry.getGpsTime()))); 475 } 476 Optional.ofNullable(entry.getIptcCaption()).map(s -> tr("\nCaption: {0}", s)).ifPresent(osd::append); 477 Optional.ofNullable(entry.getIptcHeadline()).map(s -> tr("\nHeadline: {0}", s)).ifPresent(osd::append); 478 Optional.ofNullable(entry.getIptcKeywords()).map(s -> tr("\nKeywords: {0}", s)).ifPresent(osd::append); 479 Optional.ofNullable(entry.getIptcObjectName()).map(s -> tr("\nObject name: {0}", s)).ifPresent(osd::append); 480 481 imgDisplay.setOsdText(osd.toString()); 482 } else { 483 // if this method is called to reinitialize dialog content with a blank image, 484 // do not actually show the dialog again with a blank image if currently hidden (fix #10672) 485 setTitle(tr("Geotagged Images")); 486 imgDisplay.setImage(null); 487 imgDisplay.setOsdText(""); 488 setNextEnabled(false); 489 setPreviousEnabled(false); 490 btnDelete.setEnabled(false); 491 btnDeleteFromDisk.setEnabled(false); 492 btnCopyPath.setEnabled(false); 493 return; 494 } 495 if (!isDialogShowing()) { 496 setIsDocked(false); // always open a detached window when an image is clicked and dialog is closed 497 showDialog(); 498 } else { 499 if (isDocked && isCollapsed) { 500 expand(); 501 dialogsPanel.reconstruct(Action.COLLAPSED_TO_DEFAULT, this); 502 } 503 } 504 } 505 506 /** 507 * When an image is closed, really close it and do not pop 508 * up the side dialog. 509 */ 510 @Override 511 protected boolean dockWhenClosingDetachedDlg() { 512 if (collapseButtonClicked) { 513 collapseButtonClicked = false; 514 return super.dockWhenClosingDetachedDlg(); 515 } 516 return false; 517 } 518 519 @Override 520 protected void stateChanged() { 521 super.stateChanged(); 522 if (btnCollapse != null) { 523 btnCollapse.setVisible(!isDocked); 524 } 525 } 526 527 /** 528 * Returns whether an image is currently displayed 529 * @return If image is currently displayed 530 */ 531 public boolean hasImage() { 532 return currentEntry != null; 533 } 534 535 /** 536 * Returns the currently displayed image. 537 * @return Currently displayed image or {@code null} 538 * @since 6392 539 */ 540 public static ImageEntry getCurrentImage() { 541 return getInstance().currentEntry; 542 } 543 544 /** 545 * Returns whether the center view is currently active. 546 * @return {@code true} if the center view is active, {@code false} otherwise 547 * @since 9416 548 */ 549 public static boolean isCenterView() { 550 return getInstance().centerView; 551 } 552 553 @Override 554 public void layerAdded(LayerAddEvent e) { 555 registerOnLayer(e.getAddedLayer()); 556 showLayer(e.getAddedLayer()); 557 } 558 559 @Override 560 public void layerRemoving(LayerRemoveEvent e) { 561 if (e.getRemovedLayer() instanceof GeoImageLayer) { 562 ImageData removedData = ((GeoImageLayer) e.getRemovedLayer()).getImageData(); 563 if (removedData == currentData) { 564 displayImage(null, null); 565 } 566 removedData.removeImageDataUpdateListener(this); 567 } 568 } 569 570 @Override 571 public void layerOrderChanged(LayerOrderChangeEvent e) { 572 // ignored 573 } 574 575 @Override 576 public void activeOrEditLayerChanged(ActiveLayerChangeEvent e) { 577 showLayer(e.getSource().getActiveLayer()); 578 } 579 580 private void registerOnLayer(Layer layer) { 581 if (layer instanceof GeoImageLayer) { 582 ((GeoImageLayer) layer).getImageData().addImageDataUpdateListener(this); 583 } 584 } 585 586 private void showLayer(Layer newLayer) { 587 if (currentData == null && newLayer instanceof GeoImageLayer) { 588 ((GeoImageLayer) newLayer).getImageData().selectFirstImage(); 589 } 590 } 591 592 @Override 593 public void selectedImageChanged(ImageData data) { 594 showImage(data, data.getSelectedImage()); 595 } 596 597 @Override 598 public void imageDataUpdated(ImageData data) { 599 showImage(data, data.getSelectedImage()); 600 } 601}