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 b7c35f3..cd47758 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 @@ -23,6 +23,7 @@ import javafx.geometry.Bounds; import javafx.geometry.Insets; import javafx.geometry.Pos; import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts; +import javafx.scene.Cursor; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.ProgressIndicator; @@ -31,6 +32,7 @@ import javafx.scene.control.Tooltip; import javafx.util.Duration; import javafx.scene.image.Image; import javafx.scene.image.ImageView; +import javafx.scene.input.MouseEvent; import javafx.scene.input.ScrollEvent; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; @@ -53,6 +55,11 @@ import javafx.scene.layout.VBox; * {@link ScrollPane} übernimmt das Scrollen. Das Laden einer neuen Datei setzt den * Zoom automatisch auf Fit-to-View zurück. * + *

Grab & Pan: Im manuellen Zoom-Modus kann die Vorschau durch + * Klicken und Ziehen (linke Maustaste) verschoben werden. Der Mauszeiger wechselt im + * Zoom-Modus auf {@link Cursor#OPEN_HAND} und während der Geste auf + * {@link Cursor#CLOSED_HAND}. + * *

Das Laden der PDF-Datei und das Rendering einzelner Seiten erfolgt auf einem * dedizierten Worker-Thread. UI-Updates laufen ausschließlich über den JavaFX * Application Thread. Bereits gerenderte Seiten werden in einem In-Memory-Cache @@ -124,6 +131,18 @@ public final class PdfPreviewPane { */ private double naturalViewportWidth = 0.0; + /** X-Startposition der laufenden Pan-Geste in Bildschirmkoordinaten; -1 wenn inaktiv. */ + private double panStartX = -1; + + /** Y-Startposition der laufenden Pan-Geste in Bildschirmkoordinaten; -1 wenn inaktiv. */ + private double panStartY = -1; + + /** Horizontaler Scroll-Wert zu Beginn der laufenden Pan-Geste. */ + private double panStartHvalue = 0.0; + + /** Vertikaler Scroll-Wert zu Beginn der laufenden Pan-Geste. */ + private double panStartVvalue = 0.0; + /** * Sequenznummer der aktuell angeforderten Vorschau. Jede neue Anforderung * (Laden oder Seitenwechsel) erhöht diesen Zähler. Lade-/Rendering-Ergebnisse @@ -205,6 +224,9 @@ public final class PdfPreviewPane { scrollPane.setFitToHeight(true); scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + // 32c: Verhindert, dass ScrollPane und StackPane beim manuellen Zoom mitwachsen + scrollPane.setPrefSize(0, 0); + viewStack.setMinSize(0, 0); VBox.setVgrow(scrollPane, Priority.ALWAYS); // Strg + Mausrad → Zoom; ohne Strg → normales Scrollen scrollPane.addEventFilter(ScrollEvent.SCROLL, event -> { @@ -213,6 +235,10 @@ public final class PdfPreviewPane { event.consume(); } }); + // Grab & Pan – im manuellen Zoom-Modus mit Maus verschiebbar + viewStack.addEventHandler(MouseEvent.MOUSE_PRESSED, this::onPanMousePressed); + viewStack.addEventHandler(MouseEvent.MOUSE_DRAGGED, this::onPanMouseDragged); + viewStack.addEventHandler(MouseEvent.MOUSE_RELEASED, this::onPanMouseReleased); prevButton.setId("pdf-preview-prev-button"); prevButton.setOnAction(e -> navigateToPreviousPage()); @@ -531,22 +557,90 @@ public final class PdfPreviewPane { }); } + // --- Grab & Pan ----------------------------------------------------------- + + /** + * Startet die Pan-Geste. Speichert die Startposition und den aktuellen Scroll-Zustand. + * Nur aktiv wenn der manuelle Zoom-Modus eingeschaltet ist. + * + * @param event das Maus-Pressed-Ereignis + */ + private void onPanMousePressed(MouseEvent event) { + if (scrollPane.isFitToWidth()) { + return; // Im Fit-Modus kein Pan nötig + } + panStartX = event.getScreenX(); + panStartY = event.getScreenY(); + panStartHvalue = scrollPane.getHvalue(); + panStartVvalue = scrollPane.getVvalue(); + viewStack.setCursor(Cursor.CLOSED_HAND); + event.consume(); + } + + /** + * Verschiebt den Viewport relativ zur Startposition der Pan-Geste. + * Die Scrolldelta wird auf die scrollbaren Bereiche des Inhalts normiert. + * + * @param event das Maus-Dragged-Ereignis + */ + private void onPanMouseDragged(MouseEvent event) { + if (panStartX < 0 || scrollPane.isFitToWidth()) { + return; + } + double dx = event.getScreenX() - panStartX; + double dy = event.getScreenY() - panStartY; + + Bounds viewport = scrollPane.getViewportBounds(); + double contentWidth = viewStack.getWidth(); + double contentHeight = viewStack.getHeight(); + double viewportWidth = viewport != null ? viewport.getWidth() : 0; + double viewportHeight = viewport != null ? viewport.getHeight() : 0; + + double scrollableWidth = contentWidth - viewportWidth; + double scrollableHeight = contentHeight - viewportHeight; + + if (scrollableWidth > 0) { + double newHval = panStartHvalue - dx / scrollableWidth; + scrollPane.setHvalue(Math.max(0, Math.min(1, newHval))); + } + if (scrollableHeight > 0) { + double newVval = panStartVvalue - dy / scrollableHeight; + scrollPane.setVvalue(Math.max(0, Math.min(1, newVval))); + } + event.consume(); + } + + /** + * Beendet die Pan-Geste und stellt den OPEN_HAND-Mauszeiger wieder her. + * + * @param event das Maus-Released-Ereignis + */ + private void onPanMouseReleased(MouseEvent event) { + panStartX = -1; + panStartY = -1; + if (!scrollPane.isFitToWidth()) { + viewStack.setCursor(Cursor.OPEN_HAND); + } + event.consume(); + } + // --- 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}. + * um {@value #ZOOM_STEP}. Pro ScrollEvent wird maximal eine Zoom-Stufe angewendet. * * @param deltaY vertikaler Scroll-Delta des {@link ScrollEvent} */ private void accumulateAndApplyZoomDelta(double deltaY) { zoomAccumulator += deltaY; - while (zoomAccumulator >= ZOOM_NOTCH_THRESHOLD) { + // 32a: Pro Mausrad-Raste genau eine Zoom-Stufe (if statt while verhindert + // mehrfaches Springen bei großen deltaY-Werten) + if (zoomAccumulator >= ZOOM_NOTCH_THRESHOLD) { zoomAccumulator -= ZOOM_NOTCH_THRESHOLD; applyZoom(Math.min(ZOOM_MAX, zoomLevel + ZOOM_STEP)); - } - while (zoomAccumulator <= -ZOOM_NOTCH_THRESHOLD) { + } else if (zoomAccumulator <= -ZOOM_NOTCH_THRESHOLD) { zoomAccumulator += ZOOM_NOTCH_THRESHOLD; applyZoom(Math.max(ZOOM_MIN, zoomLevel - ZOOM_STEP)); } @@ -555,13 +649,19 @@ public final class PdfPreviewPane { /** * Setzt den Zoomfaktor und verlässt beim ersten Aufruf den Fit-to-View-Modus. * Die Referenzbreite wird einmalig aus dem Viewport übernommen. + *

+ * Beim Wechsel aus dem Fit-to-View-Modus wird die Ansicht auf die Bildmitte + * zentriert (H/V = 0.5). Bei weiteren Zoom-Schritten bleibt die aktuelle + * Scrollposition erhalten. Ein {@code layout()}-Aufruf vor der Positionswiederherstellung + * stellt sicher, dass die neuen Inhaltsgrenzen bereits berechnet sind. * * @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()) { + boolean wasInFitMode = scrollPane.isFitToWidth(); + if (wasInFitMode) { Bounds viewport = scrollPane.getViewportBounds(); double vpWidth = viewport != null ? viewport.getWidth() : viewStack.getWidth(); if (vpWidth <= 0) { @@ -572,32 +672,43 @@ public final class PdfPreviewPane { scrollPane.setFitToHeight(false); imageView.fitWidthProperty().unbind(); imageView.fitHeightProperty().unbind(); + // 32d: Mauszeiger signalisiert Pan-Modus + viewStack.setCursor(Cursor.OPEN_HAND); } if (effective == zoomLevel) { return; } - double hval = scrollPane.getHvalue(); - double vval = scrollPane.getVvalue(); + // 32b: Beim ersten Zoom Mitte beibehalten; danach aktuelle Position bewahren + double hval = wasInFitMode ? 0.5 : scrollPane.getHvalue(); + double vval = wasInFitMode ? 0.5 : scrollPane.getVvalue(); + zoomLevel = effective; imageView.setFitWidth(naturalViewportWidth * zoomLevel); imageView.setFitHeight(0); + // 32b: layout() stellt sicher, dass die neuen Inhaltsgrenzen bekannt sind, + // bevor die Scroll-Werte restauriert werden Platform.runLater(() -> { + scrollPane.layout(); scrollPane.setHvalue(hval); scrollPane.setVvalue(vval); }); } /** - * Setzt Zoom und Akkumulator zurück und reaktiviert den Fit-to-View-Modus. + * Setzt Zoom, Akkumulator und Pan-Zustand 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; + // 32e: Pan-Zustand und Mauszeiger zurücksetzen + panStartX = -1; + panStartY = -1; + viewStack.setCursor(null); if (!scrollPane.isFitToWidth()) { imageView.fitWidthProperty().bind(viewStack.widthProperty()); imageView.fitHeightProperty().bind(viewStack.heightProperty());