Fix #29: Seitenanfang zuverlaessig via vvalue-Einmal-Listener erzwingen

Der bisherige ImageView-imageProperty-Listener mit Platform.runLater()
wurde von PDFViewFX nach dem Rendering noch einmal ueberschrieben, weil
die interne Scroll-Korrektur ebenfalls asynchron laeuft und spaeter
ausgefuehrt wird.

Neuer Ansatz: Nach jedem pdfView.load() und pdfView.setPage()-Aufruf
wird ein einmaliger ChangeListener auf die vvalueProperty des internen
ScrollPane registriert (scheduleScrollToTop). Sobald PDFViewFX seine
interne Scroll-Position durchschreibt und der Wert von 0 abweicht,
korrigiert der Listener ihn sofort auf 0 und entfernt sich danach selbst.
Damit greift der Eingriff immer nach dem internen PDFViewFX-Scroll,
unabhaengig von der Renderzeit.

Zusaetzlich wird ein aktiver Listener bei schnellen Seitenwechseln
(cancelScrollToTopListener) und bei clear() sauber aufgeraeumt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 14:58:43 +02:00
parent 0387be0e96
commit 8f4e18b248
@@ -11,6 +11,8 @@ import org.apache.logging.log4j.Logger;
import com.dlsc.pdfviewfx.PDFView; import com.dlsc.pdfviewfx.PDFView;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.geometry.Pos; import javafx.geometry.Pos;
import javafx.scene.Node; import javafx.scene.Node;
@@ -18,7 +20,6 @@ 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.control.ScrollPane;
import javafx.scene.image.ImageView;
import javafx.scene.input.ScrollEvent; 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;
@@ -99,17 +100,16 @@ public final class PdfPreviewPane {
/** /**
* Interner ScrollPane der PDFView-Skin. Wird nach der Skin-Installation * Interner ScrollPane der PDFView-Skin. Wird nach der Skin-Installation
* per Lookup gesetzt und für den Scroll-Schutz verwendet. * per Lookup gesetzt und für den Scroll-Schutz und den Seitenanfang-Listener verwendet.
*/ */
private ScrollPane pdfViewScrollPane = null; private ScrollPane pdfViewScrollPane = null;
/** /**
* Signalisiert, dass nach dem nächsten abgeschlossenen Rendering-Vorgang * Aktiver Einmal-Listener auf die {@code vvalueProperty} des internen ScrollPane.
* zum Seitenanfang gescrollt werden soll. Wird gesetzt beim Laden einer * Wird nach dem ersten Auslösen immer entfernt. Nie null wenn ein Seitenwechsel
* neuen Datei und bei jedem Seitenwechsel; wird vom ImageView-Listener * oder Neuladung noch aussteht, null sobald kein Eingriff mehr erwartet wird.
* nach dem Scrollen zurückgesetzt.
*/ */
private boolean pendingScrollToTop = false; private ChangeListener<Number> activeScrollToTopListener = null;
/** /**
* Erstellt die Komponente im deaktivierten Platzhalter-Zustand. * Erstellt die Komponente im deaktivierten Platzhalter-Zustand.
@@ -123,7 +123,7 @@ public final class PdfPreviewPane {
pdfView.setId("pdf-preview-view"); pdfView.setId("pdf-preview-view");
// Nach der Skin-Installation den internen ScrollPane suchen und // Nach der Skin-Installation den internen ScrollPane suchen und
// die Scroll-Filter sowie den Seitenanfang-Listener installieren. // die Scroll-Handler installieren.
pdfView.skinProperty().addListener((obs, oldSkin, newSkin) -> { pdfView.skinProperty().addListener((obs, oldSkin, newSkin) -> {
if (newSkin != null) { if (newSkin != null) {
Platform.runLater(this::installScrollHandlers); Platform.runLater(this::installScrollHandlers);
@@ -193,7 +193,6 @@ public final class PdfPreviewPane {
currentSourceFile = sourceFile; currentSourceFile = sourceFile;
currentPage = 0; currentPage = 0;
totalPages = -1; totalPages = -1;
pendingScrollToTop = true;
requestLoad(sourceFile); requestLoad(sourceFile);
} }
@@ -208,6 +207,7 @@ public final class PdfPreviewPane {
totalPages = -1; totalPages = -1;
// Neue Sequenznummer: laufende Requests werden verworfen // Neue Sequenznummer: laufende Requests werden verworfen
currentRequestSequence.incrementAndGet(); currentRequestSequence.incrementAndGet();
cancelScrollToTopListener();
pdfView.unload(); pdfView.unload();
showPlaceholder(); showPlaceholder();
updateNavigationButtons(); updateNavigationButtons();
@@ -267,11 +267,13 @@ public final class PdfPreviewPane {
return; return;
} }
int targetPage = currentPage - 1; int targetPage = currentPage - 1;
pendingScrollToTop = true;
pdfView.setPage(targetPage - 1); pdfView.setPage(targetPage - 1);
currentPage = targetPage; currentPage = targetPage;
updatePageLabel(); updatePageLabel();
updateNavigationButtons(); updateNavigationButtons();
// Nach dem Seitenwechsel stellt PDFViewFX intern die Scroll-Position ein.
// Der Einmal-Listener korrigiert sie bei Bedarf auf den Seitenanfang.
scheduleScrollToTop();
} }
private void navigateToNextPage() { private void navigateToNextPage() {
@@ -279,24 +281,24 @@ public final class PdfPreviewPane {
return; return;
} }
int targetPage = currentPage + 1; int targetPage = currentPage + 1;
pendingScrollToTop = true;
pdfView.setPage(targetPage - 1); pdfView.setPage(targetPage - 1);
currentPage = targetPage; currentPage = targetPage;
updatePageLabel(); updatePageLabel();
updateNavigationButtons(); updateNavigationButtons();
// Nach dem Seitenwechsel stellt PDFViewFX intern die Scroll-Position ein.
// Der Einmal-Listener korrigiert sie bei Bedarf auf den Seitenanfang.
scheduleScrollToTop();
} }
// --- Interne Scroll-Hilfsmethoden ---------------------------------------- // --- Scroll-Hilfsmethoden ------------------------------------------------
/** /**
* Sucht nach der Skin-Installation den internen ScrollPane der PDFView-Skin * Sucht nach der Skin-Installation den internen ScrollPane der PDFView-Skin
* und installiert dort beide Scroll-Handler: * und registriert dort den Mausrad-Seitenwechsel-Filter.
* <ol> *
* <li>Einen EventFilter gegen Mausrad-Seitenwechsel (verhindert, dass das * <p>Der Filter verhindert, dass das Mausrad an den Scroll-Grenzen automatisch
* Mausrad an den Scroll-Grenzen die Seite wechselt).</li> * die Seite wechselt; das Inhalts-Scrolling innerhalb einer Seite bleibt
* <li>Einen ImageView-Listener, der nach abgeschlossenem Rendering zum * unberührt.
* Seitenanfang scrollt, sofern {@code pendingScrollToTop} gesetzt ist.</li>
* </ol>
*/ */
private void installScrollHandlers() { private void installScrollHandlers() {
Node found = pdfView.lookup(".scroll-pane"); Node found = pdfView.lookup(".scroll-pane");
@@ -307,19 +309,6 @@ public final class PdfPreviewPane {
pdfViewScrollPane = sp; pdfViewScrollPane = sp;
LOG.debug("PDF-Vorschau: Interner ScrollPane gefunden, Scroll-Handler werden installiert"); LOG.debug("PDF-Vorschau: Interner ScrollPane gefunden, Scroll-Handler werden installiert");
installMouseWheelPageChangeFilter(sp);
installScrollToTopOnRenderComplete(sp);
}
/**
* Registriert einen EventFilter am internen ScrollPane, der Mausrad-Ereignisse
* an den Scroll-Grenzen konsumiert. Dadurch wird verhindert, dass die PDFView-Skin
* beim Erreichen des Seitenanfangs oder -endes automatisch die Seite wechselt,
* ohne dass der Benutzer die Navigations-Buttons betätigt.
*
* @param sp der interne ScrollPane der PDFView-Skin
*/
private void installMouseWheelPageChangeFilter(ScrollPane sp) {
sp.addEventFilter(ScrollEvent.SCROLL, event -> { sp.addEventFilter(ScrollEvent.SCROLL, event -> {
if (event.isInertia()) { if (event.isInertia()) {
return; return;
@@ -350,59 +339,52 @@ public final class PdfPreviewPane {
} }
/** /**
* Sucht den {@link ImageView} innerhalb des internen ScrollPane und registriert * Registriert einen einmaligen {@link ChangeListener} auf die {@code vvalueProperty}
* einen Listener auf dessen {@code imageProperty}. Sobald ein neues Seitenbild * des internen ScrollPane. PDFViewFX setzt nach dem Rendern intern die Scroll-Position;
* gerendert wurde und {@code pendingScrollToTop} gesetzt ist, scrollt die * dieser Listener greift genau dann ein, wenn der Wert von 0 abweicht, und setzt ihn
* Komponente zum Seitenanfang. Der Listener stellt sicher, dass der Skin * sofort auf den Seitenanfang zurück. Nach dem ersten Auslösen entfernt sich der
* seinen eigenen Scroll-Zustand erst abgeschlossen hat, bevor die Position * Listener selbst, um normales Inhalts-Scrolling nicht zu behindern.
* zurückgesetzt wird.
* *
* @param sp der interne ScrollPane der PDFView-Skin * <p>Ein zuvor registrierter, noch nicht ausgelöster Listener wird vor der
* Neuregistrierung entfernt (Rapid-Page-Change-Schutz).
*/ */
private void installScrollToTopOnRenderComplete(ScrollPane sp) { private void scheduleScrollToTop() {
// CSS anwenden, damit Style-Klassen für den Lookup verfügbar sind if (pdfViewScrollPane == null) {
pdfView.applyCss();
// Suche den ImageView über die bekannte CSS-Klasse des Bild-Wrappers
Node wrapperNode = sp.lookup(".image-view-wrapper");
ImageView targetImageView = null;
if (wrapperNode instanceof javafx.scene.layout.Pane wrapper) {
for (Node child : wrapper.getChildren()) {
if (child instanceof ImageView iv) {
targetImageView = iv;
break;
}
}
}
if (targetImageView == null) {
// Fallback: alle ImageViews im ScrollPane durchsuchen (da Thumbnails
// deaktiviert sind, gibt es im Hauptbereich genau einen)
for (Node n : sp.lookupAll(".image-view")) {
if (n instanceof ImageView iv) {
targetImageView = iv;
break;
}
}
}
if (targetImageView == null) {
LOG.warn("PDF-Vorschau: ImageView nicht gefunden Scrollen zum Seitenanfang nicht aktiv");
return; return;
} }
// Vorherigen Listener entfernen, falls ein schneller Seitenwechsel ihn
// noch nicht ausgelöst hat
cancelScrollToTopListener();
final ImageView imageView = targetImageView; final ScrollPane sp = pdfViewScrollPane;
imageView.imageProperty().addListener((obs, oldImg, newImg) -> { activeScrollToTopListener = new ChangeListener<>() {
// Nur scrollen, wenn ein neues Bild geliefert wurde und ein Seitenwechsel @Override
// bzw. Neuladung angefordert war public void changed(ObservableValue<? extends Number> obs,
if (newImg != null && pendingScrollToTop) { Number old, Number newVal) {
pendingScrollToTop = false; // Einmal-Listener: immer sofort entfernen
// Ein Platform.runLater() stellt sicher, dass der Skin seinen sp.vvalueProperty().removeListener(this);
// internen Scroll-Zustand zuerst abgeschlossen hat if (activeScrollToTopListener == this) {
Platform.runLater(() -> { activeScrollToTopListener = null;
}
// Nur eingreifen, wenn PDFViewFX die Position nicht bereits auf 0 gesetzt hat
if (newVal.doubleValue() > 0.0) {
sp.setVvalue(0.0); sp.setVvalue(0.0);
sp.setHvalue(0.0); sp.setHvalue(0.0);
}); }
} }
}); };
LOG.debug("PDF-Vorschau: ImageView-Listener für Seitenanfang installiert"); sp.vvalueProperty().addListener(activeScrollToTopListener);
}
/**
* Entfernt einen eventuell noch aktiven Einmal-Listener auf die {@code vvalueProperty}.
* Wird beim Verwerfen einer Anforderung (clear, Rapid-Page-Change) aufgerufen.
*/
private void cancelScrollToTopListener() {
if (activeScrollToTopListener != null && pdfViewScrollPane != null) {
pdfViewScrollPane.vvalueProperty().removeListener(activeScrollToTopListener);
activeScrollToTopListener = null;
}
} }
// --- Asynchrones Laden --------------------------------------------------- // --- Asynchrones Laden ---------------------------------------------------
@@ -457,11 +439,12 @@ public final class PdfPreviewPane {
int pages = (doc != null) ? doc.getNumberOfPages() : 1; int pages = (doc != null) ? doc.getNumberOfPages() : 1;
totalPages = Math.max(1, pages); totalPages = Math.max(1, pages);
currentPage = 1; currentPage = 1;
// PDFView zeigt nach load() bereits Seite 0 (= Seite 1); das Scrollen
// zum Seitenanfang übernimmt der ImageView-Listener sobald das Bild vorliegt
showContent(); showContent();
updateNavigationButtons(); updateNavigationButtons();
updatePageLabel(); updatePageLabel();
// PDFViewFX setzt nach dem Rendering intern die Scroll-Position.
// Der Einmal-Listener korrigiert sie bei Bedarf auf den Seitenanfang.
scheduleScrollToTop();
LOG.debug("PDF-Vorschau: Rendering angestoßen {} Seite(n)", totalPages); LOG.debug("PDF-Vorschau: Rendering angestoßen {} Seite(n)", totalPages);
} catch (Exception e) { } catch (Exception e) {
String msg = classifyLoadException(e); String msg = classifyLoadException(e);