Autopsy  4.17.0
Graphical digital forensics platform for The Sleuth Kit and other tools.
MediaViewImagePanel.java
Go to the documentation of this file.
1 /*
2  * Autopsy Forensic Browser
3  *
4  * Copyright 2018-2020 Basis Technology Corp.
5  * Contact: carrier <at> sleuthkit <dot> org
6  *
7  * Licensed under the Apache License, Version 2.0 (the "License");
8  * you may not use this file except in compliance with the License.
9  * You may obtain a copy of the License at
10  *
11  * http://www.apache.org/licenses/LICENSE-2.0
12  *
13  * Unless required by applicable law or agreed to in writing, software
14  * distributed under the License is distributed on an "AS IS" BASIS,
15  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16  * See the License for the specific language governing permissions and
17  * limitations under the License.
18  */
19 package org.sleuthkit.autopsy.contentviewers;
20 
21 import java.awt.EventQueue;
22 import java.awt.event.ActionEvent;
23 import java.awt.image.BufferedImage;
24 import java.beans.PropertyChangeEvent;
25 import java.beans.PropertyChangeListener;
26 import java.beans.PropertyChangeSupport;
27 import java.io.File;
28 import java.nio.file.Path;
29 import java.nio.file.Paths;
30 import java.util.ArrayList;
31 import java.util.Collection;
32 import java.util.Collections;
33 import java.util.List;
34 import static java.util.Objects.nonNull;
35 import java.util.SortedSet;
36 import java.util.concurrent.ExecutionException;
37 import java.util.logging.Level;
38 import java.util.stream.Collectors;
39 import javafx.application.Platform;
40 import javafx.collections.ListChangeListener.Change;
41 import javafx.concurrent.Task;
42 import javafx.embed.swing.JFXPanel;
43 import javafx.geometry.Pos;
44 import javafx.geometry.Rectangle2D;
45 import javafx.scene.Cursor;
46 import javafx.scene.Group;
47 import javafx.scene.Scene;
48 import javafx.scene.control.Button;
49 import javafx.scene.control.Label;
50 import javafx.scene.control.ProgressBar;
51 import javafx.scene.control.ScrollPane;
52 import javafx.scene.control.ScrollPane.ScrollBarPolicy;
53 import javafx.scene.image.Image;
54 import javafx.scene.image.ImageView;
55 import javafx.scene.layout.VBox;
56 import javafx.scene.transform.Rotate;
57 import javafx.scene.transform.Scale;
58 import javafx.scene.transform.Translate;
59 import javax.imageio.ImageIO;
60 import javax.swing.JFileChooser;
61 import javafx.scene.Node;
62 import javax.annotation.concurrent.Immutable;
63 import javax.swing.JMenuItem;
64 import javax.swing.JOptionPane;
65 import javax.swing.JPanel;
66 import javax.swing.JPopupMenu;
67 import javax.swing.JSeparator;
68 import javax.swing.SwingUtilities;
69 import javax.swing.SwingWorker;
70 import org.apache.commons.io.FilenameUtils;
71 import org.controlsfx.control.MaskerPane;
72 import org.openide.util.NbBundle;
73 import org.python.google.common.collect.Lists;
94 import org.sleuthkit.datamodel.AbstractFile;
95 import org.sleuthkit.datamodel.ContentTag;
96 import org.sleuthkit.datamodel.TskCoreException;
97 
103 @NbBundle.Messages({
104  "MediaViewImagePanel.externalViewerButton.text=Open in External Viewer Ctrl+E",
105  "MediaViewImagePanel.errorLabel.text=Could not load file into Media View.",
106  "MediaViewImagePanel.errorLabel.OOMText=Could not load file into Media View: insufficent memory."
107 })
108 @SuppressWarnings("PMD.SingularField") // UI widgets cause lots of false positives
109 class MediaViewImagePanel extends JPanel implements MediaFileViewer.MediaViewPanel {
110 
111  private static final long serialVersionUID = 1L;
112  private static final Logger logger = Logger.getLogger(MediaViewImagePanel.class.getName());
113  private static final SortedSet<String> supportedMimes = ImageUtils.getSupportedImageMimeTypes();
114  private static final List<String> supportedExtensions = ImageUtils.getSupportedImageExtensions().stream()
115  .map("."::concat) //NOI18N
116  .collect(Collectors.toList());
117  private static final double[] ZOOM_STEPS = {
118  0.0625, 0.125, 0.25, 0.375, 0.5, 0.75,
119  1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10};
120  private static final double MIN_ZOOM_RATIO = 0.0625; // 6.25%
121  private static final double MAX_ZOOM_RATIO = 10.0; // 1000%
122  private static final Image openInExternalViewerButtonImage = new Image(MediaViewImagePanel.class.getResource("/org/sleuthkit/autopsy/images/external.png").toExternalForm()); //NOI18N
123  private final boolean jfxIsInited = org.sleuthkit.autopsy.core.Installer.isJavaFxInited();
124  private final PropertyChangeSupport pcs = new PropertyChangeSupport(this);
125 
126  /*
127  * Threading policy: JFX UI components, must be accessed in JFX thread only.
128  */
129  @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
130  private final ProgressBar progressBar = new ProgressBar();
131  @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
132  private final MaskerPane maskerPane = new MaskerPane();
133  @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
134  private Group masterGroup;
135  @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
136  private ImageTagsGroup tagsGroup;
137  @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
138  private ImageTagCreator imageTagCreator;
139  @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
140  private ImageView fxImageView;
141  @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
142  private ScrollPane scrollPane;
143 
144  /*
145  * Threading policy: Swing UI components, must be accessed in EDT only.
146  */
147  @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
148  private final JPopupMenu imageTaggingOptions;
149  @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
150  private final JMenuItem createTagMenuItem;
151  @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
152  private final JMenuItem deleteTagMenuItem;
153  @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
154  private final JMenuItem hideTagsMenuItem;
155  @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
156  private final JMenuItem exportTagsMenuItem;
157  @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
158  private final JFileChooser exportChooser;
159  @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
160  private final JFXPanel fxPanel;
161 
162  /*
163  * Panel state variables threading policy:
164  *
165  * imageFile: The loadFile() method kicks off a JFX background task to read
166  * the content of the currently selected file into a JFX Image object. If
167  * the task succeeds and is not cancelled, the AbstractFile reference is
168  * saved as imageFile. The reference is used for tagging operations which
169  * are done in the JFX thread. IMPORTANT: Thread confinement is maintained
170  * by capturing the reference in a local variable before dispatching a tag
171  * export task to the SwingWorker thread pool. The imageFile field should
172  * not be read directly in the JFX thread.
173  *
174  * readImageFileTask: This is a reference to a JFX background task that
175  * reads the content of the currently selected file into a JFX Image object.
176  * A reference is maintained so that the task can be cancelled if it is
177  * running when the selected image file changes. Only accessed in the JFX
178  * thread.
179  *
180  * imageTransforms: These values are mostly written in the EDT based on user
181  * interactions with Swing components and then read in the JFX thread when
182  * rendering the image. The exception is recalculation of the zoom ratio
183  * based on the image size when a) the selected image file is changed, b)
184  * the panel is resized or c) the user pushes the reset button to clear any
185  * transforms they have specified. In these three cases, the zoom ratio
186  * update happens in the JFX thread since the image must be accessed.
187  * IMPORTANT: The image transforms are bundled as atomic state and a
188  * snapshot should be captured for each rendering operation on the JFX
189  * thread so that the image transforms do not change during rendering due to
190  * user interactions in the EDT.
191  */
192  @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
193  private AbstractFile imageFile;
194  @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
195  private Task<Image> readImageFileTask;
196  private volatile ImageTransforms imageTransforms;
197 
198  static {
199  ImageIO.scanForPlugins();
200  }
201 
208  @NbBundle.Messages({
209  "MediaViewImagePanel.createTagOption=Create",
210  "MediaViewImagePanel.deleteTagOption=Delete",
211  "MediaViewImagePanel.hideTagOption=Hide",
212  "MediaViewImagePanel.exportTagOption=Export"
213  })
214  MediaViewImagePanel() {
215  initComponents();
216 
217  imageTransforms = new ImageTransforms(0, 0, true);
218 
219  exportChooser = new JFileChooser();
220  exportChooser.setDialogTitle(Bundle.MediaViewImagePanel_fileChooserTitle());
221 
222  //Build popupMenu when Tags Menu button is pressed.
223  imageTaggingOptions = new JPopupMenu();
224  createTagMenuItem = new JMenuItem(Bundle.MediaViewImagePanel_createTagOption());
225  createTagMenuItem.addActionListener((event) -> createTag());
226  imageTaggingOptions.add(createTagMenuItem);
227 
228  imageTaggingOptions.add(new JSeparator());
229 
230  deleteTagMenuItem = new JMenuItem(Bundle.MediaViewImagePanel_deleteTagOption());
231  deleteTagMenuItem.addActionListener((event) -> deleteTag());
232  imageTaggingOptions.add(deleteTagMenuItem);
233 
234  imageTaggingOptions.add(new JSeparator());
235 
236  hideTagsMenuItem = new JMenuItem(Bundle.MediaViewImagePanel_hideTagOption());
237  hideTagsMenuItem.addActionListener((event) -> showOrHideTags());
238  imageTaggingOptions.add(hideTagsMenuItem);
239 
240  imageTaggingOptions.add(new JSeparator());
241 
242  exportTagsMenuItem = new JMenuItem(Bundle.MediaViewImagePanel_exportTagOption());
243  exportTagsMenuItem.addActionListener((event) -> exportTags());
244  imageTaggingOptions.add(exportTagsMenuItem);
245 
246  imageTaggingOptions.setPopupSize(300, 150);
247 
248  //Disable image tagging for non-windows users or upon failure to load OpenCV.
249  if (!PlatformUtil.isWindowsOS() || !OpenCvLoader.openCvIsLoaded()) {
250  tagsMenu.setEnabled(false);
251  imageTaggingOptions.setEnabled(false);
252  }
253 
254  fxPanel = new JFXPanel();
255  if (isInited()) {
256  Platform.runLater(new Runnable() {
257  @Override
258  public void run() {
259  // build jfx ui (we could do this in FXML?)
260  fxImageView = new ImageView(); // will hold image
261  masterGroup = new Group(fxImageView);
262  tagsGroup = new ImageTagsGroup(fxImageView);
263  tagsGroup.getChildren().addListener((Change<? extends Node> c) -> {
264  if (c.getList().isEmpty()) {
265  pcs.firePropertyChange(new PropertyChangeEvent(this,
266  "state", null, State.EMPTY));
267  }
268  });
269 
270  /*
271  * RC: I'm not sure exactly why this is located precisely
272  * here. At least putting this call outside of the
273  * constructor avoids leaking the "this" reference of a
274  * partially constructed instance of this class that is
275  * given to the PropertyChangeSupport object created at the
276  * very beginning of construction.
277  */
278  subscribeTagMenuItemsToStateChanges();
279 
280  masterGroup.getChildren().add(tagsGroup);
281 
282  //Update buttons when users select (or unselect) image tags.
283  tagsGroup.addFocusChangeListener((event) -> {
284  if (event.getPropertyName().equals(ImageTagControls.NOT_FOCUSED.getName())) {
285  if (masterGroup.getChildren().contains(imageTagCreator)) {
286  return;
287  }
288 
289  if (tagsGroup.getChildren().isEmpty()) {
290  pcs.firePropertyChange(new PropertyChangeEvent(this,
291  "state", null, State.EMPTY));
292  } else {
293  pcs.firePropertyChange(new PropertyChangeEvent(this,
294  "state", null, State.CREATE));
295  }
296  } else if (event.getPropertyName().equals(ImageTagControls.FOCUSED.getName())) {
297  pcs.firePropertyChange(new PropertyChangeEvent(this,
298  "state", null, State.SELECTED));
299  }
300  });
301 
302  scrollPane = new ScrollPane(masterGroup); // scrolls and sizes imageview
303  scrollPane.getStyleClass().add("bg"); //NOI18N
304  scrollPane.setVbarPolicy(ScrollBarPolicy.AS_NEEDED);
305  scrollPane.setHbarPolicy(ScrollBarPolicy.AS_NEEDED);
306 
307  Scene scene = new Scene(scrollPane); //root of jfx tree
308  scene.getStylesheets().add(MediaViewImagePanel.class.getResource("MediaViewImagePanel.css").toExternalForm()); //NOI18N
309  fxPanel.setScene(scene);
310 
311  fxImageView.setSmooth(true);
312  fxImageView.setCache(true);
313 
314  EventQueue.invokeLater(() -> {
315  add(fxPanel);//add jfx ui to JPanel
316  });
317  }
318  });
319  }
320  }
321 
327  private void subscribeTagMenuItemsToStateChanges() {
328  pcs.addPropertyChangeListener((event) -> {
329  State currentState = (State) event.getNewValue();
330  switch (currentState) {
331  case CREATE:
332  SwingUtilities.invokeLater(() -> {
333  createTagMenuItem.setEnabled(true);
334  deleteTagMenuItem.setEnabled(false);
335  hideTagsMenuItem.setEnabled(true);
336  exportTagsMenuItem.setEnabled(true);
337  });
338  break;
339  case SELECTED:
340  Platform.runLater(() -> {
341  if (masterGroup.getChildren().contains(imageTagCreator)) {
342  imageTagCreator.disconnect();
343  masterGroup.getChildren().remove(imageTagCreator);
344  }
345  SwingUtilities.invokeLater(() -> {
346  createTagMenuItem.setEnabled(false);
347  deleteTagMenuItem.setEnabled(true);
348  hideTagsMenuItem.setEnabled(true);
349  exportTagsMenuItem.setEnabled(true);
350  });
351  });
352  break;
353  case HIDDEN:
354  SwingUtilities.invokeLater(() -> {
355  createTagMenuItem.setEnabled(false);
356  deleteTagMenuItem.setEnabled(false);
357  hideTagsMenuItem.setEnabled(true);
358  hideTagsMenuItem.setText(DisplayOptions.SHOW_TAGS.getName());
359  exportTagsMenuItem.setEnabled(false);
360  });
361  break;
362  case VISIBLE:
363  SwingUtilities.invokeLater(() -> {
364  createTagMenuItem.setEnabled(true);
365  deleteTagMenuItem.setEnabled(false);
366  hideTagsMenuItem.setEnabled(true);
367  hideTagsMenuItem.setText(DisplayOptions.HIDE_TAGS.getName());
368  exportTagsMenuItem.setEnabled(true);
369  });
370  break;
371  case DEFAULT:
372  case EMPTY:
373  Platform.runLater(() -> {
374  if (masterGroup.getChildren().contains(imageTagCreator)) {
375  imageTagCreator.disconnect();
376  }
377  SwingUtilities.invokeLater(() -> {
378  createTagMenuItem.setEnabled(true);
379  deleteTagMenuItem.setEnabled(false);
380  hideTagsMenuItem.setEnabled(false);
381  hideTagsMenuItem.setText(DisplayOptions.HIDE_TAGS.getName());
382  exportTagsMenuItem.setEnabled(false);
383  });
384  });
385  break;
386  case NONEMPTY:
387  SwingUtilities.invokeLater(() -> {
388  createTagMenuItem.setEnabled(true);
389  deleteTagMenuItem.setEnabled(false);
390  hideTagsMenuItem.setEnabled(true);
391  exportTagsMenuItem.setEnabled(true);
392  });
393  break;
394  case DISABLE:
395  SwingUtilities.invokeLater(() -> {
396  createTagMenuItem.setEnabled(false);
397  deleteTagMenuItem.setEnabled(false);
398  hideTagsMenuItem.setEnabled(false);
399  exportTagsMenuItem.setEnabled(false);
400  });
401  break;
402  default:
403  break;
404  }
405  });
406  }
407 
408  /*
409  * Indicates whether or not the panel can be used, i.e., JavaFX has been
410  * intitialized.
411  */
412  final boolean isInited() {
413  return jfxIsInited;
414  }
415 
419  final void reset() {
420  Platform.runLater(() -> {
421  fxImageView.setViewport(new Rectangle2D(0, 0, 0, 0));
422  fxImageView.setImage(null);
423  pcs.firePropertyChange(new PropertyChangeEvent(this,
424  "state", null, State.DEFAULT));
425  masterGroup.getChildren().clear();
426  scrollPane.setContent(null);
427  scrollPane.setContent(masterGroup);
428  });
429  }
430 
438  @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
439  private void showErrorButton(String errorMessage, AbstractFile file) {
440  ensureInJfxThread();
441  final Button externalViewerButton = new Button(Bundle.MediaViewImagePanel_externalViewerButton_text(), new ImageView(openInExternalViewerButtonImage));
442  externalViewerButton.setOnAction(actionEvent
443  -> new ExternalViewerAction(Bundle.MediaViewImagePanel_externalViewerButton_text(), new FileNode(file))
444  .actionPerformed(new ActionEvent(this, ActionEvent.ACTION_PERFORMED, ""))
445  );
446  final VBox errorNode = new VBox(10, new Label(errorMessage), externalViewerButton);
447  errorNode.setAlignment(Pos.CENTER);
448  }
449 
455  @ThreadConfined(type = ThreadConfined.ThreadType.AWT)
456  final void loadFile(final AbstractFile file) {
457  ensureInSwingThread();
458  if (!isInited()) {
459  return;
460  }
461 
462  final double panelWidth = fxPanel.getWidth();
463  final double panelHeight = fxPanel.getHeight();
464  Platform.runLater(() -> {
465  /*
466  * Set up a new task to get the contents of the image file in
467  * displayable form and cancel any previous task in progress.
468  */
469  if (readImageFileTask != null) {
470  readImageFileTask.cancel();
471  }
472  readImageFileTask = ImageUtils.newReadImageTask(file);
473  readImageFileTask.setOnSucceeded(succeeded -> {
474  onReadImageTaskSucceeded(file, panelWidth, panelHeight);
475  });
476  readImageFileTask.setOnFailed(failed -> {
477  onReadImageTaskFailed(file);
478  });
479 
480  /*
481  * Update the JFX components to a "task in progress" state and start
482  * the task.
483  */
484  maskerPane.setProgressNode(progressBar);
485  progressBar.progressProperty().bind(readImageFileTask.progressProperty());
486  maskerPane.textProperty().bind(readImageFileTask.messageProperty());
487  scrollPane.setContent(null); // Prevent content display issues.
488  scrollPane.setCursor(Cursor.WAIT);
489  new Thread(readImageFileTask).start();
490  });
491  }
492 
503  private void onReadImageTaskSucceeded(AbstractFile file, double panelWidth, double panelHeight) {
504  if (!Case.isCaseOpen()) {
505  /*
506  * Handle the in-between condition when case is being closed and an
507  * image was previously selected
508  *
509  * NOTE: I think this is unnecessary -jm
510  */
511  reset();
512  return;
513  }
514 
515  Platform.runLater(() -> {
516  try {
517  Image fxImage = readImageFileTask.get();
518  masterGroup.getChildren().clear();
519  tagsGroup.getChildren().clear();
520  this.imageFile = file;
521  if (nonNull(fxImage)) {
522  // We have a non-null image, so let's show it.
523  fxImageView.setImage(fxImage);
524  if (panelWidth != 0 && panelHeight != 0) {
525  resetView(panelWidth, panelHeight);
526  }
527  masterGroup.getChildren().add(fxImageView);
528  masterGroup.getChildren().add(tagsGroup);
529 
530  try {
531  List<ContentTag> tags = Case.getCurrentCase().getServices()
532  .getTagsManager().getContentTagsByContent(file);
533 
534  List<ContentViewerTag<ImageTagRegion>> contentViewerTags = getContentViewerTags(tags);
535  //Add all image tags
536  tagsGroup = buildImageTagsGroup(contentViewerTags);
537  if (!tagsGroup.getChildren().isEmpty()) {
538  pcs.firePropertyChange(new PropertyChangeEvent(this,
539  "state", null, State.NONEMPTY));
540  }
541  } catch (TskCoreException | NoCurrentCaseException ex) {
542  logger.log(Level.WARNING, "Could not retrieve image tags for file in case db", ex); //NON-NLS
543  }
544  scrollPane.setContent(masterGroup);
545  } else {
546  showErrorButton(Bundle.MediaViewImagePanel_errorLabel_text(), file);
547  }
548  } catch (InterruptedException | ExecutionException ex) {
549  showErrorButton(Bundle.MediaViewImagePanel_errorLabel_text(), file);
550  }
551  scrollPane.setCursor(Cursor.DEFAULT);
552  });
553  }
554 
562  private void onReadImageTaskFailed(AbstractFile file) {
563  if (!Case.isCaseOpen()) {
564  /*
565  * Handle in-between condition when case is being closed and an
566  * image was previously selected
567  *
568  * NOTE: I think this is unnecessary -jm
569  */
570  reset();
571  return;
572  }
573 
574  Platform.runLater(() -> {
575  Throwable exception = readImageFileTask.getException();
576  if (exception instanceof OutOfMemoryError
577  && exception.getMessage().contains("Java heap space")) { //NON-NLS
578  showErrorButton(Bundle.MediaViewImagePanel_errorLabel_OOMText(), file);
579  } else {
580  showErrorButton(Bundle.MediaViewImagePanel_errorLabel_text(), file);
581  }
582 
583  scrollPane.setCursor(Cursor.DEFAULT);
584  });
585  }
586 
598  private List<ContentViewerTag<ImageTagRegion>> getContentViewerTags(List<ContentTag> contentTags)
599  throws TskCoreException, NoCurrentCaseException {
600  List<ContentViewerTag<ImageTagRegion>> contentViewerTags = new ArrayList<>();
601  for (ContentTag contentTag : contentTags) {
602  ContentViewerTag<ImageTagRegion> contentViewerTag = ContentViewerTagManager
603  .getTag(contentTag, ImageTagRegion.class);
604  if (contentViewerTag == null) {
605  continue;
606  }
607 
608  contentViewerTags.add(contentViewerTag);
609  }
610  return contentViewerTags;
611  }
612 
624  private ImageTagsGroup buildImageTagsGroup(List<ContentViewerTag<ImageTagRegion>> contentViewerTags) {
625  ensureInJfxThread();
626  contentViewerTags.forEach(contentViewerTag -> {
631  tagsGroup.getChildren().add(buildImageTag(contentViewerTag));
632  });
633  return tagsGroup;
634  }
635 
639  @Override
640  final public List<String> getSupportedMimeTypes() {
641  return Collections.unmodifiableList(Lists.newArrayList(supportedMimes));
642  }
643 
649  @Override
650  final public List<String> getSupportedExtensions() {
651  return getExtensions();
652  }
653 
659  final public List<String> getExtensions() {
660  return Collections.unmodifiableList(supportedExtensions);
661  }
662 
663  @Override
664  final public boolean isSupported(AbstractFile file) {
665  return ImageUtils.isImageThumbnailSupported(file);
666  }
667 
673  @SuppressWarnings("unchecked")
674  // <editor-fold defaultstate="collapsed" desc="Generated Code">//GEN-BEGIN:initComponents
675  private void initComponents() {
676 
677  toolbar = new javax.swing.JToolBar();
678  rotationTextField = new javax.swing.JTextField();
679  rotateLeftButton = new javax.swing.JButton();
680  rotateRightButton = new javax.swing.JButton();
681  jSeparator1 = new javax.swing.JToolBar.Separator();
682  zoomTextField = new javax.swing.JTextField();
683  zoomOutButton = new javax.swing.JButton();
684  zoomInButton = new javax.swing.JButton();
685  jSeparator2 = new javax.swing.JToolBar.Separator();
686  zoomResetButton = new javax.swing.JButton();
687  filler1 = new javax.swing.Box.Filler(new java.awt.Dimension(0, 0), new java.awt.Dimension(0, 0), new java.awt.Dimension(0, 0));
688  filler2 = new javax.swing.Box.Filler(new java.awt.Dimension(0, 0), new java.awt.Dimension(0, 0), new java.awt.Dimension(32767, 0));
689  jPanel1 = new javax.swing.JPanel();
690  tagsMenu = new javax.swing.JButton();
691 
692  setBackground(new java.awt.Color(0, 0, 0));
693  addComponentListener(new java.awt.event.ComponentAdapter() {
694  public void componentResized(java.awt.event.ComponentEvent evt) {
695  formComponentResized(evt);
696  }
697  });
698  setLayout(new javax.swing.BoxLayout(this, javax.swing.BoxLayout.Y_AXIS));
699 
700  toolbar.setFloatable(false);
701  toolbar.setRollover(true);
702  toolbar.setMaximumSize(new java.awt.Dimension(32767, 23));
703  toolbar.setName(""); // NOI18N
704 
705  rotationTextField.setEditable(false);
706  rotationTextField.setHorizontalAlignment(javax.swing.JTextField.RIGHT);
707  rotationTextField.setText(org.openide.util.NbBundle.getMessage(MediaViewImagePanel.class, "MediaViewImagePanel.rotationTextField.text")); // NOI18N
708  rotationTextField.setMaximumSize(new java.awt.Dimension(50, 2147483647));
709  rotationTextField.setMinimumSize(new java.awt.Dimension(50, 20));
710  rotationTextField.setPreferredSize(new java.awt.Dimension(50, 20));
711  toolbar.add(rotationTextField);
712 
713  rotateLeftButton.setIcon(new javax.swing.ImageIcon(getClass().getResource("/org/sleuthkit/autopsy/contentviewers/images/rotate-left.png"))); // NOI18N
714  org.openide.awt.Mnemonics.setLocalizedText(rotateLeftButton, org.openide.util.NbBundle.getMessage(MediaViewImagePanel.class, "MediaViewImagePanel.rotateLeftButton.text")); // NOI18N
715  rotateLeftButton.setToolTipText(org.openide.util.NbBundle.getMessage(MediaViewImagePanel.class, "MediaViewImagePanel.rotateLeftButton.toolTipText")); // NOI18N
716  rotateLeftButton.setFocusable(false);
717  rotateLeftButton.setHorizontalTextPosition(javax.swing.SwingConstants.CENTER);
718  rotateLeftButton.setMaximumSize(new java.awt.Dimension(24, 24));
719  rotateLeftButton.setMinimumSize(new java.awt.Dimension(24, 24));
720  rotateLeftButton.setPreferredSize(new java.awt.Dimension(24, 24));
721  rotateLeftButton.addActionListener(new java.awt.event.ActionListener() {
722  public void actionPerformed(java.awt.event.ActionEvent evt) {
723  rotateLeftButtonActionPerformed(evt);
724  }
725  });
726  toolbar.add(rotateLeftButton);
727 
728  rotateRightButton.setIcon(new javax.swing.ImageIcon(getClass().getResource("/org/sleuthkit/autopsy/contentviewers/images/rotate-right.png"))); // NOI18N
729  org.openide.awt.Mnemonics.setLocalizedText(rotateRightButton, org.openide.util.NbBundle.getMessage(MediaViewImagePanel.class, "MediaViewImagePanel.rotateRightButton.text")); // NOI18N
730  rotateRightButton.setFocusable(false);
731  rotateRightButton.setHorizontalTextPosition(javax.swing.SwingConstants.CENTER);
732  rotateRightButton.setMaximumSize(new java.awt.Dimension(24, 24));
733  rotateRightButton.setMinimumSize(new java.awt.Dimension(24, 24));
734  rotateRightButton.setPreferredSize(new java.awt.Dimension(24, 24));
735  rotateRightButton.addActionListener(new java.awt.event.ActionListener() {
736  public void actionPerformed(java.awt.event.ActionEvent evt) {
737  rotateRightButtonActionPerformed(evt);
738  }
739  });
740  toolbar.add(rotateRightButton);
741 
742  jSeparator1.setMaximumSize(new java.awt.Dimension(6, 20));
743  toolbar.add(jSeparator1);
744 
745  zoomTextField.setEditable(false);
746  zoomTextField.setHorizontalAlignment(javax.swing.JTextField.RIGHT);
747  zoomTextField.setText(org.openide.util.NbBundle.getMessage(MediaViewImagePanel.class, "MediaViewImagePanel.zoomTextField.text")); // NOI18N
748  zoomTextField.setMaximumSize(new java.awt.Dimension(50, 2147483647));
749  zoomTextField.setMinimumSize(new java.awt.Dimension(50, 20));
750  zoomTextField.setPreferredSize(new java.awt.Dimension(50, 20));
751  toolbar.add(zoomTextField);
752 
753  zoomOutButton.setIcon(new javax.swing.ImageIcon(getClass().getResource("/org/sleuthkit/autopsy/contentviewers/images/zoom-out.png"))); // NOI18N
754  org.openide.awt.Mnemonics.setLocalizedText(zoomOutButton, org.openide.util.NbBundle.getMessage(MediaViewImagePanel.class, "MediaViewImagePanel.zoomOutButton.text")); // NOI18N
755  zoomOutButton.setFocusable(false);
756  zoomOutButton.setHorizontalTextPosition(javax.swing.SwingConstants.CENTER);
757  zoomOutButton.setMaximumSize(new java.awt.Dimension(24, 24));
758  zoomOutButton.setMinimumSize(new java.awt.Dimension(24, 24));
759  zoomOutButton.setPreferredSize(new java.awt.Dimension(24, 24));
760  zoomOutButton.addActionListener(new java.awt.event.ActionListener() {
761  public void actionPerformed(java.awt.event.ActionEvent evt) {
762  zoomOutButtonActionPerformed(evt);
763  }
764  });
765  toolbar.add(zoomOutButton);
766 
767  zoomInButton.setIcon(new javax.swing.ImageIcon(getClass().getResource("/org/sleuthkit/autopsy/contentviewers/images/zoom-in.png"))); // NOI18N
768  org.openide.awt.Mnemonics.setLocalizedText(zoomInButton, org.openide.util.NbBundle.getMessage(MediaViewImagePanel.class, "MediaViewImagePanel.zoomInButton.text")); // NOI18N
769  zoomInButton.setFocusable(false);
770  zoomInButton.setHorizontalTextPosition(javax.swing.SwingConstants.CENTER);
771  zoomInButton.setMaximumSize(new java.awt.Dimension(24, 24));
772  zoomInButton.setMinimumSize(new java.awt.Dimension(24, 24));
773  zoomInButton.setPreferredSize(new java.awt.Dimension(24, 24));
774  zoomInButton.addActionListener(new java.awt.event.ActionListener() {
775  public void actionPerformed(java.awt.event.ActionEvent evt) {
776  zoomInButtonActionPerformed(evt);
777  }
778  });
779  toolbar.add(zoomInButton);
780 
781  jSeparator2.setMaximumSize(new java.awt.Dimension(6, 20));
782  toolbar.add(jSeparator2);
783 
784  org.openide.awt.Mnemonics.setLocalizedText(zoomResetButton, org.openide.util.NbBundle.getMessage(MediaViewImagePanel.class, "MediaViewImagePanel.zoomResetButton.text")); // NOI18N
785  zoomResetButton.setFocusable(false);
786  zoomResetButton.setHorizontalTextPosition(javax.swing.SwingConstants.CENTER);
787  zoomResetButton.setVerticalTextPosition(javax.swing.SwingConstants.BOTTOM);
788  zoomResetButton.addActionListener(new java.awt.event.ActionListener() {
789  public void actionPerformed(java.awt.event.ActionEvent evt) {
790  zoomResetButtonActionPerformed(evt);
791  }
792  });
793  toolbar.add(zoomResetButton);
794  toolbar.add(filler1);
795  toolbar.add(filler2);
796  toolbar.add(jPanel1);
797 
798  org.openide.awt.Mnemonics.setLocalizedText(tagsMenu, org.openide.util.NbBundle.getMessage(MediaViewImagePanel.class, "MediaViewImagePanel.tagsMenu.text_1")); // NOI18N
799  tagsMenu.setFocusable(false);
800  tagsMenu.setHorizontalTextPosition(javax.swing.SwingConstants.CENTER);
801  tagsMenu.setMaximumSize(new java.awt.Dimension(75, 21));
802  tagsMenu.setMinimumSize(new java.awt.Dimension(75, 21));
803  tagsMenu.setPreferredSize(new java.awt.Dimension(75, 21));
804  tagsMenu.setVerticalTextPosition(javax.swing.SwingConstants.BOTTOM);
805  tagsMenu.addMouseListener(new java.awt.event.MouseAdapter() {
806  public void mousePressed(java.awt.event.MouseEvent evt) {
807  tagsMenuMousePressed(evt);
808  }
809  });
810  toolbar.add(tagsMenu);
811 
812  add(toolbar);
813  }// </editor-fold>//GEN-END:initComponents
814 
815  private void rotateLeftButtonActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_rotateLeftButtonActionPerformed
816  rotateImage(270);
817  }//GEN-LAST:event_rotateLeftButtonActionPerformed
818 
819  private void rotateRightButtonActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_rotateRightButtonActionPerformed
820  rotateImage(90);
821  }//GEN-LAST:event_rotateRightButtonActionPerformed
822 
823  private void rotateImage(int angle) {
824  final double panelWidth = fxPanel.getWidth();
825  final double panelHeight = fxPanel.getHeight();
826  ImageTransforms currentTransforms = imageTransforms;
827  double newRotation = (currentTransforms.getRotation() + angle) % 360;
828  final ImageTransforms newTransforms = new ImageTransforms(currentTransforms.getZoomRatio(), newRotation, false);
829  imageTransforms = newTransforms;
830  Platform.runLater(() -> {
831  updateView(panelWidth, panelHeight, newTransforms);
832  });
833  }
834 
835  private void zoomInButtonActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_zoomInButtonActionPerformed
836  zoomImage(ZoomDirection.IN);
837  }//GEN-LAST:event_zoomInButtonActionPerformed
838 
839  private void zoomOutButtonActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_zoomOutButtonActionPerformed
840  zoomImage(ZoomDirection.OUT);
841  }//GEN-LAST:event_zoomOutButtonActionPerformed
842 
843  private void zoomImage(ZoomDirection direction) {
844  ensureInSwingThread();
845  final double panelWidth = fxPanel.getWidth();
846  final double panelHeight = fxPanel.getHeight();
847  final ImageTransforms currentTransforms = imageTransforms;
848  double newZoomRatio;
849  if (direction == ZoomDirection.IN) {
850  newZoomRatio = zoomImageIn(currentTransforms.getZoomRatio());
851  } else {
852  newZoomRatio = zoomImageOut(currentTransforms.getZoomRatio());
853  }
854  final ImageTransforms newTransforms = new ImageTransforms(newZoomRatio, currentTransforms.getRotation(), false);
855  imageTransforms = newTransforms;
856  Platform.runLater(() -> {
857  updateView(panelWidth, panelHeight, newTransforms);
858  });
859  }
860 
861  private double zoomImageIn(double zoomRatio) {
862  double newZoomRatio = zoomRatio;
863  for (int i = 0; i < ZOOM_STEPS.length; i++) {
864  if (newZoomRatio < ZOOM_STEPS[i]) {
865  newZoomRatio = ZOOM_STEPS[i];
866  break;
867  }
868  }
869  return newZoomRatio;
870  }
871 
872  private double zoomImageOut(double zoomRatio) {
873  double newZoomRatio = zoomRatio;
874  for (int i = ZOOM_STEPS.length - 1; i >= 0; i--) {
875  if (newZoomRatio > ZOOM_STEPS[i]) {
876  newZoomRatio = ZOOM_STEPS[i];
877  break;
878  }
879  }
880  return newZoomRatio;
881  }
882 
883  private void zoomResetButtonActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_zoomResetButtonActionPerformed
884  final ImageTransforms currentTransforms = imageTransforms;
885  final ImageTransforms newTransforms = new ImageTransforms(0, currentTransforms.getRotation(), true);
886  imageTransforms = newTransforms;
887  resetView();
888  }//GEN-LAST:event_zoomResetButtonActionPerformed
889 
890  private void formComponentResized(java.awt.event.ComponentEvent evt) {//GEN-FIRST:event_formComponentResized
891  final ImageTransforms currentTransforms = imageTransforms;
892  if (currentTransforms.shouldAutoResize()) {
893  resetView();
894  } else {
895  final double panelWidth = fxPanel.getWidth();
896  final double panelHeight = fxPanel.getHeight();
897  Platform.runLater(() -> {
898  updateView(panelWidth, panelHeight, currentTransforms);
899  });
900  }
901  }//GEN-LAST:event_formComponentResized
902 
907  private void deleteTag() {
908  Platform.runLater(() -> {
909  ImageTag tagInFocus = tagsGroup.getFocus();
910  if (tagInFocus == null) {
911  return;
912  }
913 
914  try {
915  ContentViewerTag<ImageTagRegion> contentViewerTag = tagInFocus.getContentViewerTag();
916  scrollPane.setCursor(Cursor.WAIT);
917  ContentViewerTagManager.deleteTag(contentViewerTag);
918  Case.getCurrentCase().getServices().getTagsManager().deleteContentTag(contentViewerTag.getContentTag());
919  tagsGroup.getChildren().remove(tagInFocus);
920  } catch (TskCoreException | NoCurrentCaseException ex) {
921  logger.log(Level.WARNING, "Could not delete image tag in case db", ex); //NON-NLS
922  }
923 
924  scrollPane.setCursor(Cursor.DEFAULT);
925  });
926 
927  pcs.firePropertyChange(new PropertyChangeEvent(this,
928  "state", null, State.CREATE));
929  }
930 
935  private void createTag() {
936  pcs.firePropertyChange(new PropertyChangeEvent(this,
937  "state", null, State.DISABLE));
938  Platform.runLater(() -> {
939  imageTagCreator = new ImageTagCreator(fxImageView);
940 
941  PropertyChangeListener newTagListener = (event) -> {
942  SwingUtilities.invokeLater(() -> {
943  ImageTagRegion tag = (ImageTagRegion) event.getNewValue();
944  //Ask the user for tag name and comment
945  TagNameAndComment result = GetTagNameAndCommentDialog.doDialog();
946  if (result != null) {
947  //Persist and build image tag
948  Platform.runLater(() -> {
949  try {
950  scrollPane.setCursor(Cursor.WAIT);
951  ContentViewerTag<ImageTagRegion> contentViewerTag = storeImageTag(tag, result);
952  ImageTag imageTag = buildImageTag(contentViewerTag);
953  tagsGroup.getChildren().add(imageTag);
954  } catch (TskCoreException | SerializationException | NoCurrentCaseException ex) {
955  logger.log(Level.WARNING, "Could not save new image tag in case db", ex); //NON-NLS
956  }
957 
958  scrollPane.setCursor(Cursor.DEFAULT);
959  });
960  }
961 
962  pcs.firePropertyChange(new PropertyChangeEvent(this,
963  "state", null, State.CREATE));
964  });
965 
966  //Remove image tag creator from panel
967  Platform.runLater(() -> {
968  imageTagCreator.disconnect();
969  masterGroup.getChildren().remove(imageTagCreator);
970  });
971  };
972 
973  imageTagCreator.addNewTagListener(newTagListener);
974  masterGroup.getChildren().add(imageTagCreator);
975  });
976  }
977 
985  private ImageTag buildImageTag(ContentViewerTag<ImageTagRegion> contentViewerTag) {
986  ensureInJfxThread();
987  ImageTag imageTag = new ImageTag(contentViewerTag, fxImageView);
988 
989  //Automatically persist edits made by user
990  imageTag.subscribeToEditEvents((edit) -> {
991  try {
992  scrollPane.setCursor(Cursor.WAIT);
993  ImageTagRegion newRegion = (ImageTagRegion) edit.getNewValue();
994  ContentViewerTagManager.updateTag(contentViewerTag, newRegion);
995  } catch (SerializationException | TskCoreException | NoCurrentCaseException ex) {
996  logger.log(Level.WARNING, "Could not save edit for image tag in case db", ex); //NON-NLS
997  }
998  scrollPane.setCursor(Cursor.DEFAULT);
999  });
1000  return imageTag;
1001  }
1002 
1010  private ContentViewerTag<ImageTagRegion> storeImageTag(ImageTagRegion data, TagNameAndComment result) throws TskCoreException, SerializationException, NoCurrentCaseException {
1011  ensureInJfxThread();
1012  scrollPane.setCursor(Cursor.WAIT);
1013  try {
1014  ContentTag contentTag = Case.getCurrentCaseThrows().getServices().getTagsManager()
1015  .addContentTag(imageFile, result.getTagName(), result.getComment());
1016  return ContentViewerTagManager.saveTag(contentTag, data);
1017  } finally {
1018  scrollPane.setCursor(Cursor.DEFAULT);
1019  }
1020  }
1021 
1026  private void showOrHideTags() {
1027  Platform.runLater(() -> {
1028  if (DisplayOptions.HIDE_TAGS.getName().equals(hideTagsMenuItem.getText())) {
1029  //Temporarily remove the tags group and update buttons
1030  masterGroup.getChildren().remove(tagsGroup);
1031  hideTagsMenuItem.setText(DisplayOptions.SHOW_TAGS.getName());
1032  tagsGroup.clearFocus();
1033  pcs.firePropertyChange(new PropertyChangeEvent(this,
1034  "state", null, State.HIDDEN));
1035  } else {
1036  //Add tags group back in and update buttons
1037  masterGroup.getChildren().add(tagsGroup);
1038  hideTagsMenuItem.setText(DisplayOptions.HIDE_TAGS.getName());
1039  pcs.firePropertyChange(new PropertyChangeEvent(this,
1040  "state", null, State.VISIBLE));
1041  }
1042  });
1043  }
1044 
1045  @NbBundle.Messages({
1046  "MediaViewImagePanel.exportSaveText=Save",
1047  "MediaViewImagePanel.successfulExport=Tagged image was successfully saved.",
1048  "MediaViewImagePanel.unsuccessfulExport=Unable to export tagged image to disk.",
1049  "MediaViewImagePanel.fileChooserTitle=Choose a save location"
1050  })
1051  private void exportTags() {
1052  Platform.runLater(() -> {
1053  final AbstractFile file = imageFile;
1054  tagsGroup.clearFocus();
1055  SwingUtilities.invokeLater(() -> {
1056  exportChooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
1057  //Always base chooser location to export folder
1058  exportChooser.setCurrentDirectory(new File(Case.getCurrentCase().getExportDirectory()));
1059  int returnVal = exportChooser.showDialog(this, Bundle.MediaViewImagePanel_exportSaveText());
1060  if (returnVal == JFileChooser.APPROVE_OPTION) {
1061  new SwingWorker<Void, Void>() {
1062  @Override
1063  protected Void doInBackground() {
1064  try {
1065  //Retrieve content viewer tags
1066  List<ContentTag> tags = Case.getCurrentCase().getServices()
1067  .getTagsManager().getContentTagsByContent(file);
1068  List<ContentViewerTag<ImageTagRegion>> contentViewerTags = getContentViewerTags(tags);
1069 
1070  //Pull out image tag regions
1071  Collection<ImageTagRegion> regions = contentViewerTags.stream()
1072  .map(cvTag -> cvTag.getDetails()).collect(Collectors.toList());
1073 
1074  //Apply tags to image and write to file
1075  BufferedImage taggedImage = ImageTagsUtil.getImageWithTags(file, regions);
1076  Path output = Paths.get(exportChooser.getSelectedFile().getPath(),
1077  FilenameUtils.getBaseName(file.getName()) + "-with_tags.png"); //NON-NLS
1078  ImageIO.write(taggedImage, "png", output.toFile());
1079 
1080  JOptionPane.showMessageDialog(null, Bundle.MediaViewImagePanel_successfulExport());
1081  } catch (Exception ex) { //Runtime exceptions may spill out of ImageTagsUtil from JavaFX.
1082  //This ensures we (devs and users) have something when it doesn't work.
1083  logger.log(Level.WARNING, "Unable to export tagged image to disk", ex); //NON-NLS
1084  JOptionPane.showMessageDialog(null, Bundle.MediaViewImagePanel_unsuccessfulExport());
1085  }
1086  return null;
1087  }
1088  }.execute();
1089  }
1090  });
1091  });
1092  }
1093 
1094  private void tagsMenuMousePressed(java.awt.event.MouseEvent evt) {//GEN-FIRST:event_tagsMenuMousePressed
1095  if (imageTaggingOptions.isEnabled()) {
1096  imageTaggingOptions.show(tagsMenu, -300 + tagsMenu.getWidth(), tagsMenu.getHeight() + 3);
1097  }
1098  }//GEN-LAST:event_tagsMenuMousePressed
1099 
1103  private enum DisplayOptions {
1104  HIDE_TAGS("Hide"),
1105  SHOW_TAGS("Show");
1106 
1107  private final String name;
1108 
1109  DisplayOptions(String name) {
1110  this.name = name;
1111  }
1112 
1113  String getName() {
1114  return name;
1115  }
1116  }
1117 
1122  private enum State {
1131  }
1132 
1133  // Variables declaration - do not modify//GEN-BEGIN:variables
1134  private javax.swing.Box.Filler filler1;
1135  private javax.swing.Box.Filler filler2;
1136  private javax.swing.JPanel jPanel1;
1137  private javax.swing.JToolBar.Separator jSeparator1;
1138  private javax.swing.JToolBar.Separator jSeparator2;
1139  private javax.swing.JButton rotateLeftButton;
1140  private javax.swing.JButton rotateRightButton;
1141  private javax.swing.JTextField rotationTextField;
1142  private javax.swing.JButton tagsMenu;
1143  private javax.swing.JToolBar toolbar;
1144  private javax.swing.JButton zoomInButton;
1145  private javax.swing.JButton zoomOutButton;
1146  private javax.swing.JButton zoomResetButton;
1147  private javax.swing.JTextField zoomTextField;
1148  // End of variables declaration//GEN-END:variables
1149 
1154  private void resetView() {
1155  ensureInSwingThread();
1156  final double panelWidth = fxPanel.getWidth();
1157  final double panelHeight = fxPanel.getHeight();
1158  Platform.runLater(() -> {
1159  resetView(panelWidth, panelHeight);
1160  });
1161  }
1162 
1173  private void resetView(double panelWidth, double panelHeight) {
1174  ensureInJfxThread();
1175 
1176  Image image = fxImageView.getImage();
1177  if (image == null) {
1178  return;
1179  }
1180 
1181  double imageWidth = image.getWidth();
1182  double imageHeight = image.getHeight();
1183  double scrollPaneWidth = panelWidth;
1184  double scrollPaneHeight = panelHeight;
1185  double zoomRatioWidth = scrollPaneWidth / imageWidth;
1186  double zoomRatioHeight = scrollPaneHeight / imageHeight;
1187  double newZoomRatio = zoomRatioWidth < zoomRatioHeight ? zoomRatioWidth : zoomRatioHeight; // Use the smallest ratio size to fit the entire image in the view area.
1188  final ImageTransforms newTransforms = new ImageTransforms(newZoomRatio, 0, true);
1189  imageTransforms = newTransforms;
1190 
1191  scrollPane.setHvalue(0);
1192  scrollPane.setVvalue(0);
1193 
1194  updateView(panelWidth, panelHeight, newTransforms);
1195  }
1196 
1219  private void updateView(double panelWidth, double panelHeight, ImageTransforms imageTransforms) {
1220  ensureInJfxThread();
1221  Image image = fxImageView.getImage();
1222  if (image == null) {
1223  return;
1224  }
1225 
1226  // Image dimensions
1227  double imageWidth = image.getWidth();
1228  double imageHeight = image.getHeight();
1229 
1230  // Image dimensions with zooming applied
1231  double currentZoomRatio = imageTransforms.getZoomRatio();
1232  double adjustedImageWidth = imageWidth * currentZoomRatio;
1233  double adjustedImageHeight = imageHeight * currentZoomRatio;
1234 
1235  // ImageView viewport dimensions
1236  double viewportWidth;
1237  double viewportHeight;
1238 
1239  // Coordinates to center the image on the panel
1240  double centerOffsetX = (panelWidth / 2) - (imageWidth / 2);
1241  double centerOffsetY = (panelHeight / 2) - (imageHeight / 2);
1242 
1243  // Coordinates to keep the image inside the left/top boundaries
1244  double leftOffsetX;
1245  double topOffsetY;
1246 
1247  // Scroll bar positions
1248  double scrollX = scrollPane.getHvalue();
1249  double scrollY = scrollPane.getVvalue();
1250 
1251  // Scroll bar position boundaries (work-around for viewport size bug)
1252  double maxScrollX;
1253  double maxScrollY;
1254 
1255  // Set viewport size and translation offsets.
1256  final double currentRotation = imageTransforms.getRotation();
1257  if ((currentRotation % 180) == 0) {
1258  // Rotation is 0 or 180.
1259  viewportWidth = adjustedImageWidth;
1260  viewportHeight = adjustedImageHeight;
1261  leftOffsetX = (adjustedImageWidth - imageWidth) / 2;
1262  topOffsetY = (adjustedImageHeight - imageHeight) / 2;
1263  maxScrollX = (adjustedImageWidth - panelWidth) / (imageWidth - panelWidth);
1264  maxScrollY = (adjustedImageHeight - panelHeight) / (imageHeight - panelHeight);
1265  } else {
1266  // Rotation is 90 or 270.
1267  viewportWidth = adjustedImageHeight;
1268  viewportHeight = adjustedImageWidth;
1269  leftOffsetX = (adjustedImageHeight - imageWidth) / 2;
1270  topOffsetY = (adjustedImageWidth - imageHeight) / 2;
1271  maxScrollX = (adjustedImageHeight - panelWidth) / (imageWidth - panelWidth);
1272  maxScrollY = (adjustedImageWidth - panelHeight) / (imageHeight - panelHeight);
1273  }
1274 
1275  // Work around bug that truncates image if dimensions are too small.
1276  if (viewportWidth < imageWidth) {
1277  viewportWidth = imageWidth;
1278  if (scrollX > maxScrollX) {
1279  scrollX = maxScrollX;
1280  }
1281  }
1282  if (viewportHeight < imageHeight) {
1283  viewportHeight = imageHeight;
1284  if (scrollY > maxScrollY) {
1285  scrollY = maxScrollY;
1286  }
1287  }
1288 
1289  // Update the viewport size.
1290  fxImageView.setViewport(new Rectangle2D(
1291  0, 0, viewportWidth, viewportHeight));
1292 
1293  // Step 1: Zoom
1294  Scale scale = new Scale();
1295  scale.setX(currentZoomRatio);
1296  scale.setY(currentZoomRatio);
1297  scale.setPivotX(imageWidth / 2);
1298  scale.setPivotY(imageHeight / 2);
1299 
1300  // Step 2: Rotate
1301  Rotate rotate = new Rotate();
1302  rotate.setPivotX(imageWidth / 2);
1303  rotate.setPivotY(imageHeight / 2);
1304  rotate.setAngle(currentRotation);
1305 
1306  // Step 3: Position
1307  Translate translate = new Translate();
1308  translate.setX(viewportWidth > fxPanel.getWidth() ? leftOffsetX : centerOffsetX);
1309  translate.setY(viewportHeight > fxPanel.getHeight() ? topOffsetY : centerOffsetY);
1310 
1311  // Add the transforms in reverse order of intended execution.
1312  // Note: They MUST be added in this order to ensure translate is
1313  // executed last.
1314  masterGroup.getTransforms().clear();
1315  masterGroup.getTransforms().addAll(translate, rotate, scale);
1316 
1317  // Adjust scroll bar positions for view changes.
1318  if (viewportWidth > fxPanel.getWidth()) {
1319  scrollPane.setHvalue(scrollX);
1320  }
1321  if (viewportHeight > fxPanel.getHeight()) {
1322  scrollPane.setVvalue(scrollY);
1323  }
1324 
1325  /*
1326  * RC: There is a race condition here, but it will probably be corrected
1327  * so fast the user will never see it. See Jira-6848 for details and a
1328  * solution that will simplify this class greatly in terms of thread
1329  * safety.
1330  */
1331  SwingUtilities.invokeLater(() -> {
1332  // Update all image controls to reflect the current values.
1333  zoomOutButton.setEnabled(currentZoomRatio > MIN_ZOOM_RATIO);
1334  zoomInButton.setEnabled(currentZoomRatio < MAX_ZOOM_RATIO);
1335  rotationTextField.setText((int) currentRotation + "°");
1336  zoomTextField.setText((Math.round(currentZoomRatio * 100.0)) + "%");
1337  });
1338  }
1339 
1345  private void ensureInJfxThread() {
1346  if (!Platform.isFxApplicationThread()) {
1347  throw new IllegalStateException("Attempt to execute JFX code outside of JFX thread"); //NON-NLS
1348  }
1349  }
1350 
1356  private void ensureInSwingThread() {
1357  if (!SwingUtilities.isEventDispatchThread()) {
1358  throw new IllegalStateException("Attempt to execute Swing code outside of EDT"); //NON-NLS
1359  }
1360  }
1361 
1365  private enum ZoomDirection {
1366  IN, OUT
1367  };
1368 
1372  @Immutable
1373  private static class ImageTransforms {
1374 
1375  private final double zoomRatio;
1376  private final double rotation;
1377  private final boolean autoResize;
1378 
1379  ImageTransforms(double zoomRatio, double rotation, boolean autoResize) {
1380  this.zoomRatio = zoomRatio;
1381  this.rotation = rotation;
1382  this.autoResize = autoResize;
1383  }
1384 
1390  private double getZoomRatio() {
1391  return zoomRatio;
1392  }
1393 
1400  private double getRotation() {
1401  return rotation;
1402  }
1403 
1411  private boolean shouldAutoResize() {
1412  return autoResize;
1413  }
1414 
1415  }
1416 
1417 }

Copyright © 2012-2021 Basis Technology. Generated on: Tue Jan 19 2021
This work is licensed under a Creative Commons Attribution-Share Alike 3.0 United States License.