#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.application.Platform;
|
||||||
import javafx.embed.swing.SwingFXUtils;
|
import javafx.embed.swing.SwingFXUtils;
|
||||||
|
import javafx.geometry.Bounds;
|
||||||
import javafx.geometry.Insets;
|
import javafx.geometry.Insets;
|
||||||
import javafx.geometry.Pos;
|
import javafx.geometry.Pos;
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
import javafx.scene.control.ProgressIndicator;
|
import javafx.scene.control.ProgressIndicator;
|
||||||
|
import javafx.scene.control.ScrollPane;
|
||||||
import javafx.scene.image.Image;
|
import javafx.scene.image.Image;
|
||||||
import javafx.scene.image.ImageView;
|
import javafx.scene.image.ImageView;
|
||||||
|
import javafx.scene.input.ScrollEvent;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.layout.Priority;
|
import javafx.scene.layout.Priority;
|
||||||
import javafx.scene.layout.Region;
|
import javafx.scene.layout.Region;
|
||||||
@@ -36,10 +39,16 @@ import javafx.scene.layout.VBox;
|
|||||||
* Detailbereich-Komponente zur asynchronen Anzeige von Seiten einer Quelldatei.
|
* Detailbereich-Komponente zur asynchronen Anzeige von Seiten einer Quelldatei.
|
||||||
*
|
*
|
||||||
* <p>Die Komponente rendert PDF-Seiten direkt mit Apache PDFBox und zeigt das Ergebnis
|
* <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):
|
* in einer {@link ImageView} an. Im Fit-to-View-Modus (Standardzustand) sind
|
||||||
* {@code fitWidth} und {@code fitHeight} der {@link ImageView} sind an die Größe des
|
* {@code fitWidth} und {@code fitHeight} der {@link ImageView} an die Größe des
|
||||||
* umgebenden {@link StackPane} gebunden, {@code preserveRatio=true} erhält das
|
* 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
|
* <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
|
* 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. */
|
/** Render-Auflösung in DPI. 120 DPI ist ein guter Kompromiss aus Qualität und Geschwindigkeit. */
|
||||||
private static final float RENDER_DPI = 120f;
|
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 VBox root = new VBox(4);
|
||||||
private final StackPane viewStack = new StackPane();
|
private final StackPane viewStack = new StackPane();
|
||||||
private final ImageView imageView = new ImageView();
|
private final ImageView imageView = new ImageView();
|
||||||
@@ -86,6 +107,19 @@ public final class PdfPreviewPane {
|
|||||||
private final Button prevButton = new Button("◀ Vorherige");
|
private final Button prevButton = new Button("◀ Vorherige");
|
||||||
private final Button nextButton = new Button("Nächste ▶");
|
private final Button nextButton = new Button("Nächste ▶");
|
||||||
private final Label sectionTitle = new Label("PDF-Vorschau");
|
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
|
* Sequenznummer der aktuell angeforderten Vorschau. Jede neue Anforderung
|
||||||
@@ -162,7 +196,20 @@ public final class PdfPreviewPane {
|
|||||||
StackPane.setAlignment(imageView, Pos.CENTER);
|
StackPane.setAlignment(imageView, Pos.CENTER);
|
||||||
StackPane.setAlignment(overlayLabel, Pos.CENTER);
|
StackPane.setAlignment(overlayLabel, Pos.CENTER);
|
||||||
StackPane.setAlignment(progressIndicator, 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.setId("pdf-preview-prev-button");
|
||||||
prevButton.setOnAction(e -> navigateToPreviousPage());
|
prevButton.setOnAction(e -> navigateToPreviousPage());
|
||||||
@@ -177,7 +224,7 @@ public final class PdfPreviewPane {
|
|||||||
navBar.setAlignment(Pos.CENTER);
|
navBar.setAlignment(Pos.CENTER);
|
||||||
navBar.setPadding(new Insets(4, 0, 4, 0));
|
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));
|
root.setPadding(new Insets(4, 0, 0, 0));
|
||||||
|
|
||||||
showPlaceholder();
|
showPlaceholder();
|
||||||
@@ -212,6 +259,7 @@ public final class PdfPreviewPane {
|
|||||||
currentPage = 0;
|
currentPage = 0;
|
||||||
totalPages = -1;
|
totalPages = -1;
|
||||||
pageCache.clear();
|
pageCache.clear();
|
||||||
|
resetToFitView();
|
||||||
requestLoad(sourceFile);
|
requestLoad(sourceFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +278,7 @@ public final class PdfPreviewPane {
|
|||||||
currentRequestSequence.incrementAndGet();
|
currentRequestSequence.incrementAndGet();
|
||||||
// Dokument auf dem Worker-Thread schließen, da PDDocument ausschließlich dort genutzt wird
|
// Dokument auf dem Worker-Thread schließen, da PDDocument ausschließlich dort genutzt wird
|
||||||
executor.submit(this::closeCurrentDocumentOnWorker);
|
executor.submit(this::closeCurrentDocumentOnWorker);
|
||||||
|
resetToFitView();
|
||||||
imageView.setImage(null);
|
imageView.setImage(null);
|
||||||
showPlaceholder();
|
showPlaceholder();
|
||||||
updateNavigationButtons();
|
updateNavigationButtons();
|
||||||
@@ -287,6 +336,16 @@ public final class PdfPreviewPane {
|
|||||||
return progressIndicator;
|
return progressIndicator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
ScrollPane scrollPane() {
|
||||||
|
return scrollPane;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
double zoomLevel() {
|
||||||
|
return zoomLevel;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Navigation -----------------------------------------------------------
|
// --- Navigation -----------------------------------------------------------
|
||||||
|
|
||||||
private void navigateToPreviousPage() {
|
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 ---------------------------------------------------
|
// --- UI-Zustandshelfer ---------------------------------------------------
|
||||||
|
|
||||||
private void showPlaceholder() {
|
private void showPlaceholder() {
|
||||||
|
|||||||
Reference in New Issue
Block a user