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

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