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

Copyright © 2012-2022 Basis Technology. Generated on: Tue Feb 6 2024
This work is licensed under a Creative Commons Attribution-Share Alike 3.0 United States License.