package org.jabref.gui.documentviewer;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Optional;
import javafx.animation.FadeTransition;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
import org.jabref.gui.util.BackgroundTask;
import org.jabref.gui.util.TaskExecutor;
import org.fxmisc.easybind.EasyBind;
import org.fxmisc.flowless.Cell;
import org.fxmisc.flowless.VirtualFlow;
import org.fxmisc.flowless.VirtualFlowHit;
public class DocumentViewerControl extends StackPane {
private TaskExecutor taskExecutor;
private ObjectProperty<Integer> currentPage = new SimpleObjectProperty<>(1);
private DoubleProperty scrollY = new SimpleDoubleProperty();
private DoubleProperty scrollYMax = new SimpleDoubleProperty();
private VirtualFlow<DocumentPageViewModel, DocumentViewerPage> flow;
private PageDimension desiredPageDimension = PageDimension.ofFixedWidth(600);
public DocumentViewerControl(TaskExecutor taskExecutor) {
this.taskExecutor = Objects.requireNonNull(taskExecutor);
this.getStyleClass().add("document-viewer");
// External changes to currentPage should result in scrolling to this page
EasyBind.subscribe(currentPage, this::showPage);
}
public DoubleProperty scrollYMaxProperty() {
return scrollYMax;
}
public DoubleProperty scrollYProperty() {
return scrollY;
}
public int getCurrentPage() {
return currentPage.get();
}
public ObjectProperty<Integer> currentPageProperty() {
return currentPage;
}
private void showPage(int pageNumber) {
if (flow != null) {
flow.show(pageNumber - 1);
}
}
public void show(DocumentViewModel document) {
flow = VirtualFlow.createVertical(document.getPages(), DocumentViewerPage::new);
getChildren().setAll(flow);
flow.visibleCells().addListener((ListChangeListener<? super DocumentViewerPage>) c -> updateCurrentPage(flow.visibleCells()));
// (Bidirectional) binding does not work, so use listeners instead
flow.estimatedScrollYProperty().addListener((observable, oldValue, newValue) -> scrollY.setValue(newValue));
scrollY.addListener((observable, oldValue, newValue) -> flow.estimatedScrollYProperty().setValue((double) newValue));
flow.totalLengthEstimateProperty().addListener((observable, oldValue, newValue) -> scrollYMax.setValue(newValue));
}
private void updateCurrentPage(ObservableList<DocumentViewerPage> visiblePages) {
if (flow == null) {
return;
}
// We try to find the page that is displayed in the center of the viewport
Optional<DocumentViewerPage> inMiddleOfViewport = Optional.empty();
try {
VirtualFlowHit<DocumentViewerPage> hit = flow.hit(0, flow.getHeight() / 2);
if (hit.isCellHit()) {
// Successful hit
inMiddleOfViewport = Optional.of(hit.getCell());
}
} catch (NoSuchElementException exception) {
// Sometimes throws exception if no page is found -> ignore
}
if (inMiddleOfViewport.isPresent()) {
// Successful hit
currentPage.set(inMiddleOfViewport.get().getPageNumber());
} else {
// Heuristic missed, so try to get page number from first shown page
currentPage.set(
visiblePages.stream().findFirst().map(DocumentViewerPage::getPageNumber).orElse(1));
}
}
public void setPageWidth(double width) {
desiredPageDimension = PageDimension.ofFixedWidth(width);
updateSizeOfDisplayedPages();
}
public void setPageHeight(double height) {
desiredPageDimension = PageDimension.ofFixedHeight(height);
updateSizeOfDisplayedPages();
}
private void updateSizeOfDisplayedPages() {
if (flow != null) {
for (DocumentViewerPage page : flow.visibleCells()) {
page.updateSize();
}
flow.requestLayout();
}
}
public void changePageWidth(int delta) {
// Assuming the current page is A4 (or has same aspect ratio)
setPageWidth(desiredPageDimension.getWidth(Math.sqrt(2)) + delta);
}
/**
* Represents the viewport for a page. Note: the instances of {@link DocumentViewerPage} are reused, i.e., not every
* page is rendered in a new instance but instead {@link DocumentViewerPage#updateItem(Object)} is called.
*/
private class DocumentViewerPage implements Cell<DocumentPageViewModel, StackPane> {
private final ImageView imageView;
private final StackPane imageHolder;
private final Rectangle background;
private DocumentPageViewModel page;
public DocumentViewerPage(DocumentPageViewModel initialPage) {
page = initialPage;
imageView = new ImageView();
imageHolder = new StackPane();
imageHolder.getStyleClass().add("page");
// Show progress indicator
ProgressIndicator progress = new ProgressIndicator();
progress.setMaxSize(50, 50);
// Set empty background and create proper rendering in background (for smoother loading)
background = new Rectangle(getDesiredWidth(), getDesiredHeight());
background.setStyle("-fx-fill: WHITE");
//imageView.setImage(new WritableImage(getDesiredWidth(), getDesiredHeight()));
BackgroundTask<Image> generateImage = BackgroundTask
.wrap(() -> renderPage(initialPage))
.onSuccess(image -> {
imageView.setImage(image);
progress.setVisible(false);
background.setVisible(false);
});
taskExecutor.execute(generateImage);
imageHolder.getChildren().setAll(background, progress, imageView);
}
private int getDesiredHeight() {
return desiredPageDimension.getHeight(page.getAspectRatio());
}
private int getDesiredWidth() {
return desiredPageDimension.getWidth(page.getAspectRatio());
}
@Override
public StackPane getNode() {
return imageHolder;
}
@Override
public boolean isReusable() {
return true;
}
@Override
public void updateItem(DocumentPageViewModel page) {
this.page = page;
// First hide old page and show background instead (recalculate size of background to make sure its correct)
background.setWidth(getDesiredWidth());
background.setHeight(getDesiredHeight());
background.setVisible(true);
imageView.setOpacity(0);
BackgroundTask<Image> generateImage = BackgroundTask
.wrap(() -> renderPage(page))
.onSuccess(image -> {
imageView.setImage(image);
// Fade new page in for smoother transition
FadeTransition fadeIn = new FadeTransition(Duration.millis(100), imageView);
fadeIn.setFromValue(0);
fadeIn.setToValue(1);
fadeIn.play();
});
taskExecutor.execute(generateImage);
}
private Image renderPage(DocumentPageViewModel page) {
return page.render(getDesiredWidth(), getDesiredHeight());
}
public int getPageNumber() {
return page.getPageNumber();
}
public void updateSize() {
background.setWidth(getDesiredWidth());
background.setHeight(getDesiredWidth());
updateItem(page);
imageHolder.requestLayout();
}
}
}