#32: Mausrad-Zoom (Strg+Rad) in PDF-Vorschau ergänzt
Strg + Mausrad ändert den Zoomfaktor in 10-%-Stufen (Bereich 10–500 %). Beim ersten Zoom verlässt die Vorschau den Fit-to-View-Modus; das ScrollPane übernimmt dann die Scrollbarkeit. Laden einer neuen Datei setzt den Zoom automatisch auf Fit-to-View zurück. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+139
-5
@@ -19,13 +19,16 @@ import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.embed.swing.SwingFXUtils;
|
||||
import javafx.geometry.Bounds;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ProgressIndicator;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.input.ScrollEvent;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
@@ -36,10 +39,16 @@ import javafx.scene.layout.VBox;
|
||||
* Detailbereich-Komponente zur asynchronen Anzeige von Seiten einer Quelldatei.
|
||||
*
|
||||
* <p>Die Komponente rendert PDF-Seiten direkt mit Apache PDFBox und zeigt das Ergebnis
|
||||
* in einer {@link ImageView} an. Die Anzeige ist vollständig eingepasst (fit-to-view):
|
||||
* {@code fitWidth} und {@code fitHeight} der {@link ImageView} sind an die Größe des
|
||||
* in einer {@link ImageView} an. Im Fit-to-View-Modus (Standardzustand) sind
|
||||
* {@code fitWidth} und {@code fitHeight} der {@link ImageView} an die Größe des
|
||||
* umgebenden {@link StackPane} gebunden, {@code preserveRatio=true} erhält das
|
||||
* Seitenverhältnis. Es entstehen weder Scrollbalken noch Zoom-Artefakte.
|
||||
* Seitenverhältnis. Die Seite füllt den verfügbaren Bereich ohne Scrollbalken.
|
||||
*
|
||||
* <p><strong>Mausrad-Zoom:</strong> Strg + Mausrad ändert den Zoomfaktor in Stufen von
|
||||
* 10 % pro Raste (Bereich {@value #ZOOM_MIN}–{@value #ZOOM_MAX}, d. h. 10 %–500 %).
|
||||
* Beim ersten manuellen Zoom wird der Fit-to-View-Modus verlassen und ein
|
||||
* {@link ScrollPane} übernimmt das Scrollen. Das Laden einer neuen Datei setzt den
|
||||
* Zoom automatisch auf Fit-to-View zurück.
|
||||
*
|
||||
* <p>Das Laden der PDF-Datei und das Rendering einzelner Seiten erfolgt auf einem
|
||||
* dedizierten Worker-Thread. UI-Updates laufen ausschließlich über den JavaFX
|
||||
@@ -77,6 +86,18 @@ public final class PdfPreviewPane {
|
||||
/** Render-Auflösung in DPI. 120 DPI ist ein guter Kompromiss aus Qualität und Geschwindigkeit. */
|
||||
private static final float RENDER_DPI = 120f;
|
||||
|
||||
/** Minimaler Zoomfaktor (10 %). */
|
||||
static final double ZOOM_MIN = 0.10;
|
||||
|
||||
/** Maximaler Zoomfaktor (500 %). */
|
||||
static final double ZOOM_MAX = 5.00;
|
||||
|
||||
/** Zoom-Schrittgröße pro Mausrad-Raste (10 %). */
|
||||
private static final double ZOOM_STEP = 0.10;
|
||||
|
||||
/** Typischer vertikaler Scroll-Delta pro Mausrad-Raste. */
|
||||
private static final double ZOOM_NOTCH_THRESHOLD = 40.0;
|
||||
|
||||
private final VBox root = new VBox(4);
|
||||
private final StackPane viewStack = new StackPane();
|
||||
private final ImageView imageView = new ImageView();
|
||||
@@ -86,6 +107,19 @@ public final class PdfPreviewPane {
|
||||
private final Button prevButton = new Button("◀ Vorherige");
|
||||
private final Button nextButton = new Button("Nächste ▶");
|
||||
private final Label sectionTitle = new Label("PDF-Vorschau");
|
||||
private final ScrollPane scrollPane = new ScrollPane(viewStack);
|
||||
|
||||
/** Aktueller Zoomfaktor; 1.0 entspricht der natürlichen Viewport-Breite. */
|
||||
private double zoomLevel = 1.0;
|
||||
|
||||
/** Akkumulator für sub-Rasten-Scroll-Deltas. */
|
||||
private double zoomAccumulator = 0.0;
|
||||
|
||||
/**
|
||||
* Viewport-Breite zum Zeitpunkt des ersten manuellen Zooms; dient als Referenzbreite
|
||||
* für alle nachfolgenden Zoomstufen. 0.0 bedeutet Fit-to-View-Modus ist aktiv.
|
||||
*/
|
||||
private double naturalViewportWidth = 0.0;
|
||||
|
||||
/**
|
||||
* Sequenznummer der aktuell angeforderten Vorschau. Jede neue Anforderung
|
||||
@@ -162,7 +196,20 @@ public final class PdfPreviewPane {
|
||||
StackPane.setAlignment(imageView, Pos.CENTER);
|
||||
StackPane.setAlignment(overlayLabel, Pos.CENTER);
|
||||
StackPane.setAlignment(progressIndicator, Pos.CENTER);
|
||||
VBox.setVgrow(viewStack, Priority.ALWAYS);
|
||||
|
||||
scrollPane.setId("pdf-preview-scroll-pane");
|
||||
scrollPane.setFitToWidth(true);
|
||||
scrollPane.setFitToHeight(true);
|
||||
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
|
||||
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
|
||||
VBox.setVgrow(scrollPane, Priority.ALWAYS);
|
||||
// Strg + Mausrad → Zoom; ohne Strg → normales Scrollen
|
||||
scrollPane.addEventFilter(ScrollEvent.SCROLL, event -> {
|
||||
if (event.isControlDown()) {
|
||||
accumulateAndApplyZoomDelta(event.getDeltaY());
|
||||
event.consume();
|
||||
}
|
||||
});
|
||||
|
||||
prevButton.setId("pdf-preview-prev-button");
|
||||
prevButton.setOnAction(e -> navigateToPreviousPage());
|
||||
@@ -177,7 +224,7 @@ public final class PdfPreviewPane {
|
||||
navBar.setAlignment(Pos.CENTER);
|
||||
navBar.setPadding(new Insets(4, 0, 4, 0));
|
||||
|
||||
root.getChildren().addAll(sectionTitle, viewStack, navBar);
|
||||
root.getChildren().addAll(sectionTitle, scrollPane, navBar);
|
||||
root.setPadding(new Insets(4, 0, 0, 0));
|
||||
|
||||
showPlaceholder();
|
||||
@@ -212,6 +259,7 @@ public final class PdfPreviewPane {
|
||||
currentPage = 0;
|
||||
totalPages = -1;
|
||||
pageCache.clear();
|
||||
resetToFitView();
|
||||
requestLoad(sourceFile);
|
||||
}
|
||||
|
||||
@@ -230,6 +278,7 @@ public final class PdfPreviewPane {
|
||||
currentRequestSequence.incrementAndGet();
|
||||
// Dokument auf dem Worker-Thread schließen, da PDDocument ausschließlich dort genutzt wird
|
||||
executor.submit(this::closeCurrentDocumentOnWorker);
|
||||
resetToFitView();
|
||||
imageView.setImage(null);
|
||||
showPlaceholder();
|
||||
updateNavigationButtons();
|
||||
@@ -287,6 +336,16 @@ public final class PdfPreviewPane {
|
||||
return progressIndicator;
|
||||
}
|
||||
|
||||
/** Visible for tests. */
|
||||
ScrollPane scrollPane() {
|
||||
return scrollPane;
|
||||
}
|
||||
|
||||
/** Visible for tests. */
|
||||
double zoomLevel() {
|
||||
return zoomLevel;
|
||||
}
|
||||
|
||||
// --- Navigation -----------------------------------------------------------
|
||||
|
||||
private void navigateToPreviousPage() {
|
||||
@@ -463,6 +522,81 @@ public final class PdfPreviewPane {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Zoom -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Akkumuliert den Scroll-Delta und wendet den Zoom schrittweise an.
|
||||
* Pro Raste (ca. {@value #ZOOM_NOTCH_THRESHOLD} Einheiten) ändert sich der Zoom
|
||||
* um {@value #ZOOM_STEP}.
|
||||
*
|
||||
* @param deltaY vertikaler Scroll-Delta des {@link ScrollEvent}
|
||||
*/
|
||||
private void accumulateAndApplyZoomDelta(double deltaY) {
|
||||
zoomAccumulator += deltaY;
|
||||
while (zoomAccumulator >= ZOOM_NOTCH_THRESHOLD) {
|
||||
zoomAccumulator -= ZOOM_NOTCH_THRESHOLD;
|
||||
applyZoom(Math.min(ZOOM_MAX, zoomLevel + ZOOM_STEP));
|
||||
}
|
||||
while (zoomAccumulator <= -ZOOM_NOTCH_THRESHOLD) {
|
||||
zoomAccumulator += ZOOM_NOTCH_THRESHOLD;
|
||||
applyZoom(Math.max(ZOOM_MIN, zoomLevel - ZOOM_STEP));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt den Zoomfaktor und verlässt beim ersten Aufruf den Fit-to-View-Modus.
|
||||
* Die Referenzbreite wird einmalig aus dem Viewport übernommen.
|
||||
*
|
||||
* @param newZoom gewünschter Zoomfaktor, wird auf [{@link #ZOOM_MIN}, {@link #ZOOM_MAX}] begrenzt
|
||||
*/
|
||||
private void applyZoom(double newZoom) {
|
||||
double effective = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom));
|
||||
|
||||
if (scrollPane.isFitToWidth()) {
|
||||
Bounds viewport = scrollPane.getViewportBounds();
|
||||
double vpWidth = viewport != null ? viewport.getWidth() : viewStack.getWidth();
|
||||
if (vpWidth <= 0) {
|
||||
return; // Layout noch nicht abgeschlossen
|
||||
}
|
||||
naturalViewportWidth = vpWidth;
|
||||
scrollPane.setFitToWidth(false);
|
||||
scrollPane.setFitToHeight(false);
|
||||
imageView.fitWidthProperty().unbind();
|
||||
imageView.fitHeightProperty().unbind();
|
||||
}
|
||||
|
||||
if (effective == zoomLevel) {
|
||||
return;
|
||||
}
|
||||
|
||||
double hval = scrollPane.getHvalue();
|
||||
double vval = scrollPane.getVvalue();
|
||||
zoomLevel = effective;
|
||||
imageView.setFitWidth(naturalViewportWidth * zoomLevel);
|
||||
imageView.setFitHeight(0);
|
||||
|
||||
Platform.runLater(() -> {
|
||||
scrollPane.setHvalue(hval);
|
||||
scrollPane.setVvalue(vval);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt Zoom und Akkumulator zurück und reaktiviert den Fit-to-View-Modus.
|
||||
* Wird beim Laden einer neuen Datei und beim Leeren der Komponente aufgerufen.
|
||||
*/
|
||||
private void resetToFitView() {
|
||||
zoomLevel = 1.0;
|
||||
zoomAccumulator = 0.0;
|
||||
naturalViewportWidth = 0.0;
|
||||
if (!scrollPane.isFitToWidth()) {
|
||||
imageView.fitWidthProperty().bind(viewStack.widthProperty());
|
||||
imageView.fitHeightProperty().bind(viewStack.heightProperty());
|
||||
scrollPane.setFitToWidth(true);
|
||||
scrollPane.setFitToHeight(true);
|
||||
}
|
||||
}
|
||||
|
||||
// --- UI-Zustandshelfer ---------------------------------------------------
|
||||
|
||||
private void showPlaceholder() {
|
||||
|
||||
Reference in New Issue
Block a user