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%) │ * │ Ergebnisliste (60%) │ Detailbereich (40%) │
* │ (TableView + Checkboxen) │ KI-Begründung (kompakt) │ * │ (TableView + Checkboxen) │ KI-Begründung (kompakt) │
* │ │ Dateiname-Editor │ * │ │ Dateiname-Editor │
* │ │ PDF-Vorschau (Restplatz) │ * ├───────────────────────────┤ PDF-Vorschau (Restplatz) │
* ├───────────────────────────┴──────────────────────────────┤ * │ [Erneut ver.] [Zurückset.]│ │
* │ [Erneut verarbeiten] [Status zurücksetzen] * │ Meldungsbereich │
* ──────────────────────────────────────────────────────────┤ * └───────────────────────────┴──────────────────────────────
* │ Meldungs- und Zusammenfassungsbereich * [Starten] [Abbrechen]
* ├──────────────────────────────────────────────────────────┤
* │ [Starten] [Abbrechen] │
* └──────────────────────────────────────────────────────────┘
* </pre> * </pre>
* *
* <h2>Threading</h2> * <h2>Threading</h2>
@@ -490,11 +487,31 @@ public final class GuiBatchRunTab {
tableScroll.setId("batch-run-result-scroll"); tableScroll.setId("batch-run-result-scroll");
resultTable.setMinHeight(LIST_MIN_HEIGHT); resultTable.setMinHeight(LIST_MIN_HEIGHT);
// Detailbereich: KI-Begründung oben (kompakt), darunter Dateiname-Editor, // Selektions-Aktions-Buttons unterhalb der Tabelle (linke Spalte)
// darunter PDF-Vorschau (nimmt verbleibenden Platz) 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(); VBox detailBox = buildDetailPane();
SplitPane splitPane = new SplitPane(tableScroll, detailBox); SplitPane splitPane = new SplitPane(leftColumn, detailBox);
splitPane.setId("batch-run-split-pane"); splitPane.setId("batch-run-split-pane");
splitPane.setDividerPositions(SPLIT_DIVIDER_POSITION); splitPane.setDividerPositions(SPLIT_DIVIDER_POSITION);
SplitPane.setResizableWithParent(detailBox, true); SplitPane.setResizableWithParent(detailBox, true);
@@ -920,22 +937,6 @@ public final class GuiBatchRunTab {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
private Region buildFooter() { 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 // Lauf-Steuerungs-Buttons
startButton.setId("batch-run-start"); startButton.setId("batch-run-start");
startButton.setOnAction(event -> handleStart()); startButton.setOnAction(event -> handleStart());
@@ -948,8 +949,7 @@ public final class GuiBatchRunTab {
runButtonBar.setAlignment(Pos.CENTER_LEFT); runButtonBar.setAlignment(Pos.CENTER_LEFT);
runButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2, 0, 0, 0)); runButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2, 0, 0, 0));
VBox footer = new VBox(SECONDARY_SPACING / 2, selectionButtonBar, messageArea, runButtonBar); return runButtonBar;
return footer;
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -11,8 +11,6 @@ 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;
@@ -20,7 +18,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.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;
@@ -30,16 +27,15 @@ 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 zeigt die Seiten einer PDF-Datei mit Seitennavigation an. * <p>Die Komponente zeigt eine einzelne Seite der PDF-Datei vollständig eingepasst
* Das Laden erfolgt auf einem Hintergrund-Worker-Thread; UI-Updates laufen * (fit-to-view) an. Das Laden erfolgt auf einem Hintergrund-Worker-Thread; UI-Updates
* ausschließlich über den JavaFX Application Thread. * 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 * <p>Es gilt das Prinzip „Latest Preview Request Wins": Veraltete Lade-Ergebnisse
* steuert Laden, Fehlerbehandlung und den Ladeindikator. * werden verworfen, sobald eine neue Anforderung eingeht.
*
* <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.
* *
* <h2>Fehlerfälle</h2> * <h2>Fehlerfälle</h2>
* <ul> * <ul>
@@ -50,7 +46,8 @@ import javafx.scene.layout.VBox;
* *
* <h2>Threading</h2> * <h2>Threading</h2>
* <p>Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen * <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 { public final class PdfPreviewPane {
@@ -99,17 +96,10 @@ public final class PdfPreviewPane {
private boolean enabled = true; private boolean enabled = true;
/** /**
* Interner ScrollPane der PDFView-Skin. Wird nach der Skin-Installation * Zuletzt ermitteltes Seitenverhältnis (Höhe/Breite) der gerenderten Seite.
* per Lookup gesetzt und für den Scroll-Schutz und den Seitenanfang-Listener verwendet. * Wird asynchron nach dem Laden gesetzt; -1.0 wenn noch nicht bekannt.
*/ */
private ScrollPane pdfViewScrollPane = null; private double lastKnownAspectRatio = -1.0;
/**
* 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;
/** /**
* Erstellt die Komponente im deaktivierten Platzhalter-Zustand. * Erstellt die Komponente im deaktivierten Platzhalter-Zustand.
@@ -122,11 +112,16 @@ public final class PdfPreviewPane {
pdfView.setShowToolBar(false); pdfView.setShowToolBar(false);
pdfView.setId("pdf-preview-view"); pdfView.setId("pdf-preview-view");
// Nach der Skin-Installation den internen ScrollPane suchen und // Nach der Skin-Installation Scrollbalken im internen ScrollPane ausblenden.
// 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(() -> {
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); StackPane.setAlignment(progressIndicator, Pos.CENTER);
VBox.setVgrow(viewStack, Priority.ALWAYS); 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.setId("pdf-preview-prev-button");
prevButton.setOnAction(e -> navigateToPreviousPage()); prevButton.setOnAction(e -> navigateToPreviousPage());
@@ -205,9 +204,9 @@ public final class PdfPreviewPane {
currentSourceFile = null; currentSourceFile = null;
currentPage = 0; currentPage = 0;
totalPages = -1; totalPages = -1;
lastKnownAspectRatio = -1.0;
// 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();
@@ -271,9 +270,7 @@ public final class PdfPreviewPane {
currentPage = targetPage; currentPage = targetPage;
updatePageLabel(); updatePageLabel();
updateNavigationButtons(); updateNavigationButtons();
// Nach dem Seitenwechsel stellt PDFViewFX intern die Scroll-Position ein. fitToView();
// Der Einmal-Listener korrigiert sie bei Bedarf auf den Seitenanfang.
scheduleScrollToTop();
} }
private void navigateToNextPage() { private void navigateToNextPage() {
@@ -285,106 +282,72 @@ public final class PdfPreviewPane {
currentPage = targetPage; currentPage = targetPage;
updatePageLabel(); updatePageLabel();
updateNavigationButtons(); updateNavigationButtons();
// Nach dem Seitenwechsel stellt PDFViewFX intern die Scroll-Position ein. fitToView();
// Der Einmal-Listener korrigiert sie bei Bedarf auf den Seitenanfang.
scheduleScrollToTop();
} }
// --- Scroll-Hilfsmethoden ------------------------------------------------ // --- Zoom-Berechnung (fit-to-view) ----------------------------------------
/** /**
* Sucht nach der Skin-Installation den internen ScrollPane der PDFView-Skin * Berechnet den optimalen Zoomfaktor anhand des bekannten Seitenverhältnisses und
* und registriert dort den Mausrad-Seitenwechsel-Filter. * 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
* <p>Der Filter verhindert, dass das Mausrad an den Scroll-Grenzen automatisch * Anzeigefläche noch keine Größe hat.
* die Seite wechselt; das Inhalts-Scrolling innerhalb einer Seite bleibt
* unberührt.
*/ */
private void installScrollHandlers() { private void fitToView() {
Node found = pdfView.lookup(".scroll-pane"); if (lastKnownAspectRatio <= 0.0) {
if (!(found instanceof ScrollPane sp)) {
LOG.warn("PDF-Vorschau: Interner ScrollPane nicht gefunden Scroll-Handler nicht aktiv");
return; return;
} }
pdfViewScrollPane = sp; double vw = viewStack.getWidth();
LOG.debug("PDF-Vorschau: Interner ScrollPane gefunden, Scroll-Handler werden installiert"); 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()) { * 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; return;
} }
Node content = sp.getContent(); int pageIdx = Math.max(0, currentPage - 1);
if (content == null) { executor.submit(() -> {
event.consume(); try {
return; // Seite bei sehr kleiner Skala rendern, um das Seitenverhältnis zu ermitteln
} var img = doc.renderPage(pageIdx, 0.05f);
double contentH = content.getBoundsInLocal().getHeight(); if (img != null && img.getWidth() > 0) {
double viewportH = sp.getViewportBounds().getHeight(); double ratio = (double) img.getHeight() / img.getWidth();
boolean hatUeberlauf = contentH > viewportH + 1.0; Platform.runLater(() -> {
if (currentRequestSequence.get() == seq) {
if (!hatUeberlauf) { lastKnownAspectRatio = ratio;
// Seite passt vollständig in den Viewport: kein Inhalts-Scrolling möglich, fitToView();
// 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();
} }
}); });
} }
} 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 --------------------------------------------------- // --- Asynchrones Laden ---------------------------------------------------
@@ -442,9 +405,8 @@ public final class PdfPreviewPane {
showContent(); showContent();
updateNavigationButtons(); updateNavigationButtons();
updatePageLabel(); updatePageLabel();
// PDFViewFX setzt nach dem Rendering intern die Scroll-Position. // Seitenverhältnis asynchron ermitteln und Zoom anpassen
// Der Einmal-Listener korrigiert sie bei Bedarf auf den Seitenanfang. fetchAspectRatioAsync(seq);
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);