Fix #29: Layout-Umbau und fit-to-view PDF-Vorschau ohne Scrollbalken

GuiBatchRunTab: Buttons "Erneut verarbeiten" / "Status zurücksetzen" und
Meldungsbereich in die linke SplitPane-Spalte unterhalb der Tabelle
verschoben. Detailbereich (rechte Spalte) erstreckt sich dadurch vollständig
von oben bis unten – mehr Platz für die PDF-Vorschau.

PdfPreviewPane: Gesamten suppressScrollReset / ChangeListener-Code entfernt.
Seite wird jetzt immer fit-to-view ohne Scrollbalken angezeigt: Seitenverhältnis
wird asynchron per renderPage(0.05f) ermittelt, Zoom über setZoomFactor() gesetzt
und bei Größenänderungen der Anzeigefläche automatisch neu berechnet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 15:28:29 +02:00
parent 8f4e18b248
commit 71d79ab30c
2 changed files with 114 additions and 152 deletions
@@ -84,14 +84,11 @@ import javafx.scene.layout.VBox;
* │ Ergebnisliste (60%) │ Detailbereich (40%) │
* │ (TableView + Checkboxen) │ KI-Begründung (kompakt) │
* │ │ Dateiname-Editor │
* │ │ PDF-Vorschau (Restplatz) │
* ├───────────────────────────┴──────────────────────────────┤
* │ [Erneut verarbeiten] [Status zurücksetzen]
* ──────────────────────────────────────────────────────────┤
* │ Meldungs- und Zusammenfassungsbereich
* ├──────────────────────────────────────────────────────────┤
* │ [Starten] [Abbrechen] │
* └──────────────────────────────────────────────────────────┘
* ├───────────────────────────┤ PDF-Vorschau (Restplatz) │
* │ [Erneut ver.] [Zurückset.]│ │
* │ Meldungsbereich │
* └───────────────────────────┴──────────────────────────────
* [Starten] [Abbrechen]
* </pre>
*
* <h2>Threading</h2>
@@ -490,11 +487,31 @@ public final class GuiBatchRunTab {
tableScroll.setId("batch-run-result-scroll");
resultTable.setMinHeight(LIST_MIN_HEIGHT);
// Detailbereich: KI-Begründung oben (kompakt), darunter Dateiname-Editor,
// darunter PDF-Vorschau (nimmt verbleibenden Platz)
// Selektions-Aktions-Buttons unterhalb der Tabelle (linke Spalte)
reprocessButton.setId("batch-run-reprocess");
reprocessButton.setOnAction(event -> handleReprocessSelected());
resetStatusButton.setId("batch-run-reset-status");
resetStatusButton.setOnAction(event -> handleResetSelected());
HBox selectionButtonBar = new HBox(SECONDARY_SPACING, reprocessButton, resetStatusButton);
selectionButtonBar.setAlignment(Pos.CENTER_LEFT);
selectionButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2, 0, 0, 0));
// Meldungsbereich unterhalb der Selektions-Buttons (linke Spalte)
messageArea.setId("batch-run-message-area");
messageArea.setEditable(false);
messageArea.setWrapText(true);
messageArea.setPrefRowCount(3);
// Linke Spalte: Tabelle wächst, Buttons und Meldungsbereich darunter
VBox leftColumn = new VBox(0, tableScroll, selectionButtonBar, messageArea);
VBox.setVgrow(tableScroll, Priority.ALWAYS);
// Detailbereich rechts erstreckt sich vollständig von oben bis unten
VBox detailBox = buildDetailPane();
SplitPane splitPane = new SplitPane(tableScroll, detailBox);
SplitPane splitPane = new SplitPane(leftColumn, detailBox);
splitPane.setId("batch-run-split-pane");
splitPane.setDividerPositions(SPLIT_DIVIDER_POSITION);
SplitPane.setResizableWithParent(detailBox, true);
@@ -920,22 +937,6 @@ public final class GuiBatchRunTab {
// -------------------------------------------------------------------------
private Region buildFooter() {
messageArea.setId("batch-run-message-area");
messageArea.setEditable(false);
messageArea.setWrapText(true);
messageArea.setPrefRowCount(3);
// Selektions-Aktions-Buttons
reprocessButton.setId("batch-run-reprocess");
reprocessButton.setOnAction(event -> handleReprocessSelected());
resetStatusButton.setId("batch-run-reset-status");
resetStatusButton.setOnAction(event -> handleResetSelected());
HBox selectionButtonBar = new HBox(SECONDARY_SPACING, reprocessButton, resetStatusButton);
selectionButtonBar.setAlignment(Pos.CENTER_LEFT);
selectionButtonBar.setPadding(new Insets(SECONDARY_SPACING, 0, 0, 0));
// Lauf-Steuerungs-Buttons
startButton.setId("batch-run-start");
startButton.setOnAction(event -> handleStart());
@@ -948,8 +949,7 @@ public final class GuiBatchRunTab {
runButtonBar.setAlignment(Pos.CENTER_LEFT);
runButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2, 0, 0, 0));
VBox footer = new VBox(SECONDARY_SPACING / 2, selectionButtonBar, messageArea, runButtonBar);
return footer;
return runButtonBar;
}
// -------------------------------------------------------------------------
@@ -11,8 +11,6 @@ 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;
@@ -20,7 +18,6 @@ import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.ScrollPane;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
@@ -30,16 +27,15 @@ import javafx.scene.layout.VBox;
/**
* Detailbereich-Komponente zur asynchronen Anzeige von Seiten einer Quelldatei.
*
* <p>Die Komponente zeigt die Seiten einer PDF-Datei mit Seitennavigation an.
* Das Laden erfolgt auf einem Hintergrund-Worker-Thread; UI-Updates laufen
* ausschließlich über den JavaFX Application Thread.
* <p>Die Komponente zeigt eine einzelne Seite der PDF-Datei vollständig eingepasst
* (fit-to-view) an. Das Laden erfolgt auf einem Hintergrund-Worker-Thread; UI-Updates
* laufen ausschließlich über den JavaFX Application Thread. Nach dem Laden wird das
* Seitenverhältnis asynchron ermittelt und der Zoom so gesetzt, dass die Seite ohne
* Scrollbalken vollständig sichtbar ist. Bei Größenänderungen der Anzeigefläche wird
* der Zoom automatisch neu berechnet.
*
* <p>PDFView übernimmt intern das Rendern und die Darstellung. Diese Komponente
* steuert Laden, Fehlerbehandlung und den Ladeindikator.
*
* <p>Beim Selektionswechsel wird eine neue Lade-Anforderung ausgelöst. Es gilt das
* Prinzip „Latest Preview Request Wins": Veraltete Lade-Ergebnisse werden
* verworfen, sobald eine neue Anforderung eingeht.
* <p>Es gilt das Prinzip „Latest Preview Request Wins": Veraltete Lade-Ergebnisse
* werden verworfen, sobald eine neue Anforderung eingeht.
*
* <h2>Fehlerfälle</h2>
* <ul>
@@ -50,7 +46,8 @@ import javafx.scene.layout.VBox;
*
* <h2>Threading</h2>
* <p>Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen
* werden. Internes Laden läuft auf einem dedizierten Worker-Thread.
* werden. Internes Laden und Seitenverhältnis-Ermittlung laufen auf einem dedizierten
* Worker-Thread.
*/
public final class PdfPreviewPane {
@@ -99,17 +96,10 @@ public final class PdfPreviewPane {
private boolean enabled = true;
/**
* Interner ScrollPane der PDFView-Skin. Wird nach der Skin-Installation
* per Lookup gesetzt und für den Scroll-Schutz und den Seitenanfang-Listener verwendet.
* Zuletzt ermitteltes Seitenverhältnis (Höhe/Breite) der gerenderten Seite.
* Wird asynchron nach dem Laden gesetzt; -1.0 wenn noch nicht bekannt.
*/
private ScrollPane pdfViewScrollPane = null;
/**
* 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 ChangeListener<Number> activeScrollToTopListener = null;
private double lastKnownAspectRatio = -1.0;
/**
* Erstellt die Komponente im deaktivierten Platzhalter-Zustand.
@@ -122,11 +112,16 @@ public final class PdfPreviewPane {
pdfView.setShowToolBar(false);
pdfView.setId("pdf-preview-view");
// Nach der Skin-Installation den internen ScrollPane suchen und
// die Scroll-Handler installieren.
// Nach der Skin-Installation Scrollbalken im internen ScrollPane ausblenden.
pdfView.skinProperty().addListener((obs, oldSkin, newSkin) -> {
if (newSkin != null) {
Platform.runLater(this::installScrollHandlers);
Platform.runLater(() -> {
Node found = pdfView.lookup(".scroll-pane");
if (found instanceof ScrollPane sp) {
sp.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
sp.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
}
});
}
});
@@ -148,6 +143,10 @@ public final class PdfPreviewPane {
StackPane.setAlignment(progressIndicator, Pos.CENTER);
VBox.setVgrow(viewStack, Priority.ALWAYS);
// Bei Größenänderungen der Anzeigefläche Zoom neu berechnen
viewStack.widthProperty().addListener((obs, ov, nv) -> fitToView());
viewStack.heightProperty().addListener((obs, ov, nv) -> fitToView());
prevButton.setId("pdf-preview-prev-button");
prevButton.setOnAction(e -> navigateToPreviousPage());
@@ -205,9 +204,9 @@ public final class PdfPreviewPane {
currentSourceFile = null;
currentPage = 0;
totalPages = -1;
lastKnownAspectRatio = -1.0;
// Neue Sequenznummer: laufende Requests werden verworfen
currentRequestSequence.incrementAndGet();
cancelScrollToTopListener();
pdfView.unload();
showPlaceholder();
updateNavigationButtons();
@@ -271,9 +270,7 @@ public final class PdfPreviewPane {
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();
fitToView();
}
private void navigateToNextPage() {
@@ -285,108 +282,74 @@ public final class PdfPreviewPane {
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();
fitToView();
}
// --- Scroll-Hilfsmethoden ------------------------------------------------
// --- Zoom-Berechnung (fit-to-view) ----------------------------------------
/**
* Sucht nach der Skin-Installation den internen ScrollPane der PDFView-Skin
* 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.
* Berechnet den optimalen Zoomfaktor anhand des bekannten Seitenverhältnisses und
* der aktuellen Größe der Anzeigefläche und setzt ihn auf {@code pdfView}.
* Hat keinen Effekt wenn das Seitenverhältnis noch nicht bekannt ist oder die
* Anzeigefläche noch keine Größe hat.
*/
private void installScrollHandlers() {
Node found = pdfView.lookup(".scroll-pane");
if (!(found instanceof ScrollPane sp)) {
LOG.warn("PDF-Vorschau: Interner ScrollPane nicht gefunden Scroll-Handler nicht aktiv");
private void fitToView() {
if (lastKnownAspectRatio <= 0.0) {
return;
}
pdfViewScrollPane = sp;
LOG.debug("PDF-Vorschau: Interner ScrollPane gefunden, Scroll-Handler werden installiert");
double vw = viewStack.getWidth();
double vh = viewStack.getHeight();
if (vw <= 0.0 || vh <= 0.0) {
return;
}
PDFView.Document doc = pdfView.getDocument();
if (doc == null || currentPage <= 0) {
return;
}
// Querformat: Zoom=1 → Höhe füllt den Viewport; Breite kann überlaufen.
// Hochformat: Zoom=1 → Breite füllt den Viewport; Höhe kann überlaufen.
boolean landscape = doc.isLandscape(currentPage - 1);
double zoom;
if (!landscape) {
zoom = Math.min(1.0, vh / (lastKnownAspectRatio * vw));
} else {
zoom = Math.min(1.0, vw / (lastKnownAspectRatio * vh));
}
pdfView.setZoomFactor(Math.max(0.05, zoom));
}
sp.addEventFilter(ScrollEvent.SCROLL, event -> {
if (event.isInertia()) {
return;
}
Node content = sp.getContent();
if (content == null) {
event.consume();
return;
}
double contentH = content.getBoundsInLocal().getHeight();
double viewportH = sp.getViewportBounds().getHeight();
boolean hatUeberlauf = contentH > viewportH + 1.0;
if (!hatUeberlauf) {
// Seite passt vollständig in den Viewport: kein Inhalts-Scrolling möglich,
// daher Event konsumieren, damit kein Seitenwechsel ausgelöst wird
event.consume();
return;
}
// Seite hat überlaufenden Inhalt: Event nur an den Scroll-Grenzen konsumieren
boolean scrolltHoch = event.getDeltaY() > 0;
double vVal = sp.getVvalue();
boolean anGrenze = scrolltHoch ? (vVal <= 0.0) : (vVal >= 1.0);
if (anGrenze) {
event.consume();
/**
* Ermittelt das Seitenverhältnis der angegebenen Seite asynchron auf dem
* Worker-Thread und aktualisiert danach den Zoom auf dem FX-Thread.
* Veraltete Ergebnisse werden anhand der Sequenznummer verworfen.
*
* @param seq die Sequenznummer der aktuellen Lade-Anforderung
*/
private void fetchAspectRatioAsync(long seq) {
PDFView.Document doc = pdfView.getDocument();
if (doc == null) {
return;
}
int pageIdx = Math.max(0, currentPage - 1);
executor.submit(() -> {
try {
// Seite bei sehr kleiner Skala rendern, um das Seitenverhältnis zu ermitteln
var img = doc.renderPage(pageIdx, 0.05f);
if (img != null && img.getWidth() > 0) {
double ratio = (double) img.getHeight() / img.getWidth();
Platform.runLater(() -> {
if (currentRequestSequence.get() == seq) {
lastKnownAspectRatio = ratio;
fitToView();
}
});
}
} catch (Exception e) {
LOG.debug("PDF-Vorschau: Seitenverhältnis nicht ermittelbar: {}", e.getMessage());
}
});
}
/**
* 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.
*
* <p>Ein zuvor registrierter, noch nicht ausgelöster Listener wird vor der
* Neuregistrierung entfernt (Rapid-Page-Change-Schutz).
*/
private void scheduleScrollToTop() {
if (pdfViewScrollPane == null) {
return;
}
// Vorherigen Listener entfernen, falls ein schneller Seitenwechsel ihn
// noch nicht ausgelöst hat
cancelScrollToTopListener();
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);
}
}
};
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 ---------------------------------------------------
/**
@@ -442,9 +405,8 @@ public final class PdfPreviewPane {
showContent();
updateNavigationButtons();
updatePageLabel();
// PDFViewFX setzt nach dem Rendering intern die Scroll-Position.
// Der Einmal-Listener korrigiert sie bei Bedarf auf den Seitenanfang.
scheduleScrollToTop();
// Seitenverhältnis asynchron ermitteln und Zoom anpassen
fetchAspectRatioAsync(seq);
LOG.debug("PDF-Vorschau: Rendering angestoßen {} Seite(n)", totalPages);
} catch (Exception e) {
String msg = classifyLoadException(e);