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 javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
@@ -18,7 +20,6 @@ import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.ScrollPane;
import javafx.scene.image.ImageView;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
@@ -99,17 +100,16 @@ public final class PdfPreviewPane {
/**
* 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;
/**
* Signalisiert, dass nach dem nächsten abgeschlossenen Rendering-Vorgang
* zum Seitenanfang gescrollt werden soll. Wird gesetzt beim Laden einer
* neuen Datei und bei jedem Seitenwechsel; wird vom ImageView-Listener
* nach dem Scrollen zurückgesetzt.
* Aktiver Einmal-Listener auf die {@code vvalueProperty} des internen ScrollPane.
* Wird nach dem ersten Auslösen immer entfernt. Nie null wenn ein Seitenwechsel
* oder Neuladung noch aussteht, null sobald kein Eingriff mehr erwartet wird.
*/
private boolean pendingScrollToTop = false;
private ChangeListener<Number> activeScrollToTopListener = null;
/**
* Erstellt die Komponente im deaktivierten Platzhalter-Zustand.
@@ -123,7 +123,7 @@ public final class PdfPreviewPane {
pdfView.setId("pdf-preview-view");
// 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) -> {
if (newSkin != null) {
Platform.runLater(this::installScrollHandlers);
@@ -193,7 +193,6 @@ public final class PdfPreviewPane {
currentSourceFile = sourceFile;
currentPage = 0;
totalPages = -1;
pendingScrollToTop = true;
requestLoad(sourceFile);
}
@@ -208,6 +207,7 @@ public final class PdfPreviewPane {
totalPages = -1;
// Neue Sequenznummer: laufende Requests werden verworfen
currentRequestSequence.incrementAndGet();
cancelScrollToTopListener();
pdfView.unload();
showPlaceholder();
updateNavigationButtons();
@@ -267,11 +267,13 @@ public final class PdfPreviewPane {
return;
}
int targetPage = currentPage - 1;
pendingScrollToTop = true;
pdfView.setPage(targetPage - 1);
currentPage = targetPage;
updatePageLabel();
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() {
@@ -279,24 +281,24 @@ public final class PdfPreviewPane {
return;
}
int targetPage = currentPage + 1;
pendingScrollToTop = true;
pdfView.setPage(targetPage - 1);
currentPage = targetPage;
updatePageLabel();
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
* und installiert dort beide Scroll-Handler:
* <ol>
* <li>Einen EventFilter gegen Mausrad-Seitenwechsel (verhindert, dass das
* Mausrad an den Scroll-Grenzen die Seite wechselt).</li>
* <li>Einen ImageView-Listener, der nach abgeschlossenem Rendering zum
* Seitenanfang scrollt, sofern {@code pendingScrollToTop} gesetzt ist.</li>
* </ol>
* und registriert dort den Mausrad-Seitenwechsel-Filter.
*
* <p>Der Filter verhindert, dass das Mausrad an den Scroll-Grenzen automatisch
* die Seite wechselt; das Inhalts-Scrolling innerhalb einer Seite bleibt
* unberührt.
*/
private void installScrollHandlers() {
Node found = pdfView.lookup(".scroll-pane");
@@ -307,19 +309,6 @@ public final class PdfPreviewPane {
pdfViewScrollPane = sp;
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 -> {
if (event.isInertia()) {
return;
@@ -350,59 +339,52 @@ public final class PdfPreviewPane {
}
/**
* Sucht den {@link ImageView} innerhalb des internen ScrollPane und registriert
* einen Listener auf dessen {@code imageProperty}. Sobald ein neues Seitenbild
* gerendert wurde und {@code pendingScrollToTop} gesetzt ist, scrollt die
* Komponente zum Seitenanfang. Der Listener stellt sicher, dass der Skin
* seinen eigenen Scroll-Zustand erst abgeschlossen hat, bevor die Position
* zurückgesetzt wird.
* Registriert einen einmaligen {@link ChangeListener} auf die {@code vvalueProperty}
* des internen ScrollPane. PDFViewFX setzt nach dem Rendern intern die Scroll-Position;
* dieser Listener greift genau dann ein, wenn der Wert von 0 abweicht, und setzt ihn
* sofort auf den Seitenanfang zurück. Nach dem ersten Auslösen entfernt sich der
* Listener selbst, um normales Inhalts-Scrolling nicht zu behindern.
*
* @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) {
// CSS anwenden, damit Style-Klassen für den Lookup verfügbar sind
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");
private void scheduleScrollToTop() {
if (pdfViewScrollPane == null) {
return;
}
// Vorherigen Listener entfernen, falls ein schneller Seitenwechsel ihn
// noch nicht ausgelöst hat
cancelScrollToTopListener();
final ImageView imageView = targetImageView;
imageView.imageProperty().addListener((obs, oldImg, newImg) -> {
// Nur scrollen, wenn ein neues Bild geliefert wurde und ein Seitenwechsel
// bzw. Neuladung angefordert war
if (newImg != null && pendingScrollToTop) {
pendingScrollToTop = false;
// Ein Platform.runLater() stellt sicher, dass der Skin seinen
// internen Scroll-Zustand zuerst abgeschlossen hat
Platform.runLater(() -> {
final ScrollPane sp = pdfViewScrollPane;
activeScrollToTopListener = new ChangeListener<>() {
@Override
public void changed(ObservableValue<? extends Number> obs,
Number old, Number newVal) {
// Einmal-Listener: immer sofort entfernen
sp.vvalueProperty().removeListener(this);
if (activeScrollToTopListener == this) {
activeScrollToTopListener = null;
}
// Nur eingreifen, wenn PDFViewFX die Position nicht bereits auf 0 gesetzt hat
if (newVal.doubleValue() > 0.0) {
sp.setVvalue(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 ---------------------------------------------------
@@ -457,11 +439,12 @@ public final class PdfPreviewPane {
int pages = (doc != null) ? doc.getNumberOfPages() : 1;
totalPages = Math.max(1, pages);
currentPage = 1;
// PDFView zeigt nach load() bereits Seite 0 (= Seite 1); das Scrollen
// zum Seitenanfang übernimmt der ImageView-Listener sobald das Bild vorliegt
showContent();
updateNavigationButtons();
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);
} catch (Exception e) {
String msg = classifyLoadException(e);