diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/PdfPreviewPane.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/PdfPreviewPane.java index 2926ecb..68ef6e7 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/PdfPreviewPane.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/PdfPreviewPane.java @@ -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. * *

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. + * + *

Mausrad-Zoom: 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. * *

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() {