#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:
2026-05-05 12:31:12 +02:00
parent 1ffd565bd7
commit beade6ba2e
@@ -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() {