package com.megatim.manuel.core.viewer;
|
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.megatim.manuel.core.generate.HelpExporter;
|
import com.megatim.manuel.core.generate.HelpManifest;
|
import com.megatim.manuel.core.generate.HelpToc;
|
import com.megatim.manuel.core.theme.ManuelTheme;
|
|
import javafx.beans.binding.Bindings;
|
import javafx.geometry.Insets;
|
import javafx.scene.Parent;
|
import javafx.scene.control.Button;
|
import javafx.scene.control.Label;
|
import javafx.scene.control.Separator;
|
import javafx.scene.control.SplitPane;
|
import javafx.scene.control.TextField;
|
import javafx.scene.control.ToolBar;
|
import javafx.scene.control.Tooltip;
|
import javafx.scene.control.TreeCell;
|
import javafx.scene.control.TreeItem;
|
import javafx.scene.control.TreeView;
|
import javafx.scene.image.Image;
|
import javafx.scene.image.ImageView;
|
import javafx.scene.layout.BorderPane;
|
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.Priority;
|
import javafx.scene.layout.Region;
|
import javafx.scene.layout.VBox;
|
import javafx.scene.web.WebHistory;
|
import javafx.scene.web.WebView;
|
|
import java.io.IOException;
|
import java.nio.charset.StandardCharsets;
|
import java.nio.file.Files;
|
import java.nio.file.Path;
|
import java.util.ArrayList;
|
import java.util.List;
|
|
/**
|
* Viewer du manuel JavaFX : affiche un dossier d'aide HTML (décrit par toc.json) avec
|
* sommaire arborescent, recherche, navigation et zoom.
|
*
|
* <p>Composant embarquable : {@link #build()} retourne un {@link Parent} à poser dans
|
* n'importe quel conteneur. La charte (couleurs, logo, CSS) est portée par le
|
* {@link ManuelTheme} passé au constructeur (défaut : charte Sirius). Les erreurs
|
* (dossier ou toc.json absent/illisible) remontent en {@link IOException}, à afficher
|
* par l'application hôte.</p>
|
*/
|
public class HelpViewer {
|
|
private final Path folder;
|
private final ManuelTheme theme;
|
private HelpManifest manifest;
|
|
private final WebView web = new WebView();
|
private final TreeView<HelpToc> tree = new TreeView<>();
|
private final TextField search = new TextField();
|
private final Label status = new Label();
|
private double zoom = 1.0;
|
private final Label zoomLabel = new Label("100 %");
|
|
/** Viewer au thème par défaut (charte Sirius). */
|
public HelpViewer(Path folder) {
|
this(folder, ManuelTheme.defaultTheme());
|
}
|
|
public HelpViewer(Path folder, ManuelTheme theme) {
|
this.folder = folder;
|
this.theme = theme;
|
}
|
|
public String title() {
|
if (theme.getAppTitle() != null && !theme.getAppTitle().isBlank()) {
|
StringBuilder t = new StringBuilder(theme.getAppTitle().trim());
|
if (manifest != null && manifest.version != null && !manifest.version.isBlank()) {
|
t.append(" — v").append(manifest.version.trim());
|
}
|
return t.toString();
|
}
|
if (manifest == null) return "Aide";
|
StringBuilder t = new StringBuilder();
|
if (manifest.project != null && !manifest.project.isBlank()) t.append(manifest.project.trim());
|
else if (manifest.title != null) t.append(manifest.title);
|
else t.append("Aide");
|
if (manifest.version != null && !manifest.version.isBlank()) t.append(" — v").append(manifest.version.trim());
|
return t.toString();
|
}
|
|
public Parent build() throws IOException {
|
Path tocFile = folder.resolve(HelpExporter.TOC_JSON);
|
if (!Files.exists(tocFile)) {
|
throw new IOException("Dossier d'aide invalide : " + HelpExporter.TOC_JSON + " introuvable dans " + folder);
|
}
|
String json = new String(Files.readAllBytes(tocFile), StandardCharsets.UTF_8);
|
ObjectMapper mapper = new ObjectMapper()
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
manifest = mapper.readValue(json, HelpManifest.class);
|
if (manifest == null) throw new IOException("toc.json illisible.");
|
|
// --- Sommaire ---
|
tree.setShowRoot(false);
|
tree.setCellFactory(tv -> new TreeCell<>() {
|
@Override protected void updateItem(HelpToc n, boolean empty) {
|
super.updateItem(n, empty);
|
setText(empty || n == null ? null : n.title);
|
}
|
});
|
tree.setRoot(buildTreeItems(manifest.nodes));
|
tree.getSelectionModel().selectedItemProperty().addListener((o, a, b) -> {
|
if (b != null && b.getValue() != null && b.getValue().href != null) load(b.getValue().href);
|
});
|
|
search.setPromptText("Rechercher dans le sommaire…");
|
search.textProperty().addListener((o, a, q) -> tree.setRoot(buildTreeItems(filter(manifest.nodes, q))));
|
|
// En-tête : logo éventuel + titre (thème ou projet du manifest) + version
|
VBox header = new VBox(1);
|
String headTitle = theme.getAppTitle() != null && !theme.getAppTitle().isBlank()
|
? theme.getAppTitle().trim()
|
: (manifest.project != null && !manifest.project.isBlank() ? manifest.project.trim() : null);
|
if (headTitle != null) {
|
Label proj = new Label(headTitle);
|
proj.getStyleClass().add("help-project-title");
|
proj.setWrapText(true);
|
if (theme.getLogo() != null) {
|
ImageView logo = new ImageView(new Image(theme.getLogo().toExternalForm(), 24, 24, true, true));
|
proj.setGraphic(logo);
|
}
|
header.getChildren().add(proj);
|
}
|
if (manifest.version != null && !manifest.version.isBlank()) {
|
Label ver = new Label("Version " + manifest.version.trim());
|
ver.getStyleClass().add("help-project-version");
|
header.getChildren().add(ver);
|
}
|
|
VBox left = new VBox(6);
|
left.getStyleClass().add("help-left-panel");
|
if (!header.getChildren().isEmpty()) {
|
left.getChildren().addAll(header, new Separator());
|
}
|
Label sommaireTitle = new Label("Sommaire");
|
sommaireTitle.getStyleClass().add("help-panel-title");
|
left.getChildren().addAll(sommaireTitle, search, tree);
|
left.setPadding(new Insets(8));
|
left.setPrefWidth(330);
|
VBox.setVgrow(tree, Priority.ALWAYS);
|
|
// --- Barre d'outils : navigation dans l'historique + accueil + zoom ---
|
Button back = new Button("Précédent");
|
Button fwd = new Button("Suivant");
|
Button home = new Button("Accueil");
|
home.getStyleClass().add("button-primary");
|
back.setTooltip(new Tooltip("Revenir à la page consultée précédemment"));
|
fwd.setTooltip(new Tooltip("Avancer vers la page suivante de l'historique"));
|
home.setTooltip(new Tooltip("Revenir à la page d'accueil du manuel"));
|
WebHistory history = web.getEngine().getHistory();
|
back.setOnAction(e -> history.go(-1));
|
fwd.setOnAction(e -> history.go(1));
|
home.setOnAction(e -> { if (manifest.home != null) load(manifest.home); });
|
// Grises tant qu'il n'y a rien avant/apres dans l'historique
|
back.disableProperty().bind(history.currentIndexProperty().lessThanOrEqualTo(0));
|
fwd.disableProperty().bind(Bindings.createBooleanBinding(
|
() -> history.getCurrentIndex() >= history.getEntries().size() - 1,
|
history.currentIndexProperty(), history.getEntries()));
|
Button zMinus = new Button("A-");
|
Button zPlus = new Button("A+");
|
Button zReset = new Button("100 %");
|
zMinus.setTooltip(new Tooltip("Réduire la taille du texte"));
|
zPlus.setTooltip(new Tooltip("Augmenter la taille du texte"));
|
zReset.setTooltip(new Tooltip("Rétablir la taille par défaut (100 %)"));
|
zMinus.setOnAction(e -> setZoom(zoom - 0.1));
|
zPlus.setOnAction(e -> setZoom(zoom + 0.1));
|
zReset.setOnAction(e -> setZoom(1.0));
|
Region sp = new Region();
|
HBox.setHgrow(sp, Priority.ALWAYS);
|
ToolBar bar = new ToolBar(back, fwd, home, sp, new Label("Zoom :"), zMinus, zoomLabel, zPlus, zReset);
|
|
web.setZoom(zoom);
|
VBox right = new VBox(bar, web);
|
VBox.setVgrow(web, Priority.ALWAYS);
|
|
SplitPane split = new SplitPane(left, right);
|
split.setDividerPositions(0.30);
|
SplitPane.setResizableWithParent(left, false);
|
|
BorderPane root = new BorderPane(split);
|
status.getStyleClass().add("help-status");
|
status.setPadding(new Insets(3, 8, 3, 8));
|
root.setBottom(status);
|
|
// Charte : looked-up colors + feuilles de style du theme
|
root.getStyleClass().add("help-viewer");
|
theme.applyTo(root);
|
|
if (manifest.home != null) load(manifest.home);
|
selectFirst();
|
return root;
|
}
|
|
private void setZoom(double z) {
|
zoom = Math.max(0.5, Math.min(3.0, z));
|
web.setZoom(zoom);
|
zoomLabel.setText(Math.round(zoom * 100) + " %");
|
}
|
|
private void load(String href) {
|
if (href == null) return;
|
String file = href;
|
String frag = null;
|
int h = href.indexOf('#');
|
if (h >= 0) { file = href.substring(0, h); frag = href.substring(h + 1); }
|
String uri = folder.resolve(file).toUri().toString();
|
if (frag != null && !frag.isEmpty()) uri += "#" + frag;
|
web.getEngine().load(uri);
|
status.setText(href);
|
}
|
|
private void selectFirst() {
|
if (!tree.getRoot().getChildren().isEmpty()) {
|
tree.getSelectionModel().select(tree.getRoot().getChildren().get(0));
|
}
|
}
|
|
private TreeItem<HelpToc> buildTreeItems(List<HelpToc> nodes) {
|
TreeItem<HelpToc> root = new TreeItem<>(new HelpToc("root", null));
|
addChildren(root, nodes, 0);
|
return root;
|
}
|
|
private void addChildren(TreeItem<HelpToc> parent, List<HelpToc> nodes, int depth) {
|
if (nodes == null) return;
|
for (HelpToc n : nodes) {
|
TreeItem<HelpToc> item = new TreeItem<>(n);
|
// Document déplié ; petits dossiers (Préambule…) dépliés ; gros (Sommaire/Illustrations) repliés
|
int kids = n.children == null ? 0 : n.children.size();
|
item.setExpanded(depth == 0 || (depth == 1 && kids > 0 && kids <= 8));
|
addChildren(item, n.children, depth + 1);
|
parent.getChildren().add(item);
|
}
|
}
|
|
/** Filtre l'arbre : garde un nœud s'il correspond OU s'il a un descendant correspondant. */
|
private List<HelpToc> filter(List<HelpToc> nodes, String query) {
|
if (query == null || query.isBlank()) return nodes;
|
String q = query.toLowerCase();
|
List<HelpToc> out = new ArrayList<>();
|
for (HelpToc n : nodes) {
|
List<HelpToc> kids = filter(n.children, query);
|
boolean self = n.title != null && n.title.toLowerCase().contains(q);
|
if (self || !kids.isEmpty()) {
|
HelpToc copy = new HelpToc(n.title, n.href);
|
copy.children = kids;
|
out.add(copy);
|
}
|
}
|
return out;
|
}
|
}
|