Implementiere PDF-Vorschau: Zoom-Verbesserungen und Grab & Pan

32c: ScrollPane.setPrefSize(0,0) und StackPane.setMinSize(0,0) verhindern,
dass der Vorschaubereich beim manuellen Zoom mitwächst.

32a: Zoom-Akkumulator nutzt if statt while – pro Mausrad-Raste wird genau
eine Zoom-Stufe (10 %) angewendet, auch bei großen deltaY-Werten.

32b: Beim ersten Zoom-Einstieg wird die Ansicht auf die Bildmitte
zentriert (H/V = 0.5). scrollPane.layout() vor der Scroll-Wert-
Restaurierung stellt sicher, dass die neuen Inhaltsgrenzen bekannt sind.

32d: Grab & Pan – im manuellen Zoom-Modus kann die Vorschau mit der Maus
verschoben werden. OPEN_HAND-Cursor signalisiert den Zoom-Modus,
CLOSED_HAND die aktive Pan-Geste.

32e: resetToFitView() setzt Pan-Zustand und Mauszeiger zurück, sodass
beim Laden einer neuen Datei der Fit-to-View-Modus vollständig
wiederhergestellt wird.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 14:09:44 +02:00
parent 0412874f08
commit 9c27e4df01
@@ -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.
*
* <p><strong>Grab &amp; Pan:</strong> 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}.
*
* <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
* 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.
* <p>
* 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());