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:
+29
-29
@@ -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;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
+85
-123
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user