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. * *

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.

*/ public class HelpViewer { private final Path folder; private final ManuelTheme theme; private HelpManifest manifest; private final WebView web = new WebView(); private final TreeView 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 buildTreeItems(List nodes) { TreeItem root = new TreeItem<>(new HelpToc("root", null)); addChildren(root, nodes, 0); return root; } private void addChildren(TreeItem parent, List nodes, int depth) { if (nodes == null) return; for (HelpToc n : nodes) { TreeItem 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 filter(List nodes, String query) { if (query == null || query.isBlank()) return nodes; String q = query.toLowerCase(); List out = new ArrayList<>(); for (HelpToc n : nodes) { List 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; } }