From d92f75a8035d2309287cb2170ffadf50032b5533 Mon Sep 17 00:00:00 2001 From: william Date: Mon, 21 Sep 2026 09:03:47 +0200 Subject: [PATCH] Remplace la modale liste+menus deroulants du Scenario par un vrai graphe visuel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retour utilisateur : "passons a un veritable graphe visuel". Chaque nœud gagne une position x/y (document_engine/labels/scenario_config.py : un x/y manquant/invalide retombe sur un quadrillage en cascade derive de l'index du nœud, jamais (0, 0) pour tous les nœuds qui les empilerait au meme endroit). Cote editeur (static/document/js/document-editor.js) : les nœuds sont des cartes deplacables a la souris sur un canevas (glisser-deposer reel, meme principe que le glisser des formes libres), les choix relies a une cible sont dessines comme des fleches SVG etiquetees par leur texte (jamais un menu deroulant). Editer le texte/les choix d'un nœud se fait dans un panneau inspecteur (colonne de droite) pour le nœud selectionne ; relier un choix se fait en cliquant "Relier" puis le nœud cible sur le graphe (mode connexion, Echap annule sans fermer la modale). La modale generique (.docModal*) est agrandie specifiquement pour ce graphe (jusqu'a 1180px) sans toucher sa taille par defaut. Verifie via simulation DOM reelle (jsdom) : rendu des nœuds/positions, glisser-deposer avec persistance au relachement, traces des fleches SVG + etiquettes, workflow complet du mode connexion, suppression d'un nœud avec reparation des references pendantes, et les 3 façons de fermer la modale (bouton/fond/Echap) y compris l'annulation du mode connexion sans fermer. Co-Authored-By: Claude Sonnet 5 --- document_engine/labels/labels.md | 28 +- document_engine/labels/scenario_config.py | 50 +- document_engine/rendering/rendering.md | 11 +- static/document/document-editor.css | 214 ++++++++ static/document/js/document-editor.js | 567 ++++++++++++++++------ tests/document/test_scenario_config.py | 66 ++- 6 files changed, 777 insertions(+), 159 deletions(-) diff --git a/document_engine/labels/labels.md b/document_engine/labels/labels.md index 1b8bad2f..c09c89c4 100644 --- a/document_engine/labels/labels.md +++ b/document_engine/labels/labels.md @@ -198,14 +198,17 @@ convention resolve_X/sanitize_X que `quiz_config.py`. **Structure d'un scénario** : `{"title": str, "nodes": [...]}`. `nodes[0]` est TOUJOURS la situation initiale (racine de l'arbre, jamais supprimable depuis le panneau Propriétés — voir -`forgeDocRenderScenarioNodeHtml`, static/document/js/document-editor.js). -Chaque nœud est `{"id": str, "text": str, "choices": [...]}` — `id` est -une référence STABLE (générée côté client, jamais recalculée côté -serveur) vers laquelle un choix d'un AUTRE nœud peut pointer via son -`target_id` ; un nœud SANS choix est une fin de branche (état normal, -pas une erreur — contrairement à l'ancien modèle qui exigeait 2 à 4 -choix par scénario). Un choix est `{"text": str, "target_id": str | -None}` — `target_id` à `None` signifie "fin de branche à ce choix". +`forgeDocScenarioGraphRenderInspector`, static/document/js/ +document-editor.js). Chaque nœud est `{"id": str, "text": str, +"x": float, "y": float, "choices": [...]}` — `id` est une référence +STABLE (générée côté client, jamais recalculée côté serveur) vers +laquelle un choix d'un AUTRE nœud peut pointer via son `target_id` ; +`x`/`y` positionnent le nœud sur le canevas du graphe visuel (voir +`forgeDocRenderScenarioTreeModal`) ; un nœud SANS choix est une fin de +branche (état normal, pas une erreur — contrairement à l'ancien modèle +qui exigeait 2 à 4 choix par scénario). Un choix est `{"text": str, +"target_id": str | None}` — `target_id` à `None` signifie "fin de +branche à ce choix". ### `MAX_SCENARIO_CHOICES: int` Borne technique du nombre de choix par nœud (`4`) — évite une UI @@ -230,8 +233,13 @@ est validé indépendamment (voir `_sanitize_scenario`/ `_sanitize_scenario_node`/`_sanitize_scenario_choice`, privées) : le `title` doit être non vide, chaque nœud doit avoir un `id` (chaîne non vide) et un `text` non vide une fois `.strip()`-é (sinon le nœud entier -est supprimé), et ses choix sont tronqués à `MAX_SCENARIO_CHOICES`. Le -scénario entier est supprimé s'il ne reste plus aucun nœud valide après +est supprimé), et ses choix sont tronqués à `MAX_SCENARIO_CHOICES`. Un +`x`/`y` manquant ou invalide (pas un nombre, ou un booléen — `bool` +hérite de `int` en Python) retombe sur un quadrillage en cascade dérivé +de l'INDEX du nœud dans la liste (`_scenario_node_fallback_position`), +jamais `(0, 0)` pour tous les nœuds, ce qui les empilerait exactement au +même endroit sur le graphe visuel. Le scénario entier est supprimé s'il +ne reste plus aucun nœud valide après nettoyage (il faut au moins la situation initiale). Une fois l'ensemble des ids valides connu, tout `target_id` qui ne pointe plus vers un nœud existant (nœud invalide/supprimé) est silencieusement remis à `None` diff --git a/document_engine/labels/scenario_config.py b/document_engine/labels/scenario_config.py index ea4b079f..e8a13708 100644 --- a/document_engine/labels/scenario_config.py +++ b/document_engine/labels/scenario_config.py @@ -16,16 +16,27 @@ jamais, et renvoie toujours un dict complet. Structure d'un scénario : {"title": str, "nodes": [...]}. `nodes[0]` est TOUJOURS la situation initiale (racine de l'arbre, jamais supprimable depuis le panneau Propriétés). Chaque nœud est -{"id": str, "text": str, "choices": [...]} — `id` est une référence -STABLE (générée côté client, jamais recalculée ici) vers laquelle un -choix d'un AUTRE nœud peut pointer via son `target_id` ; un nœud sans -choix est une fin de branche. Un choix est {"text": str, -"target_id": str | None} — `target_id` à None signifie "fin de branche -à ce choix" (aucun nœud suivant).""" +{"id": str, "text": str, "x": float, "y": float, "choices": [...]} — +`id` est une référence STABLE (générée côté client, jamais recalculée +ici) vers laquelle un choix d'un AUTRE nœud peut pointer via son +`target_id` ; un nœud sans choix est une fin de branche. `x`/`y` +positionnent le nœud sur le canevas du graphe visuel (voir +document_engine/rendering/render_document_element.py:: +_render_scenario_player et forgeDocRenderScenarioGraph, static/document/ +js/document-editor.js) — une position manquante/invalide retombe sur un +quadrillage en cascade calculé depuis l'INDEX du nœud dans la liste +(_scenario_node_fallback_position), jamais (0, 0) pour tous les nœuds, +ce qui les empilerait exactement au même endroit. Un choix est +{"text": str, "target_id": str | None} — `target_id` à None signifie +"fin de branche à ce choix" (aucun nœud suivant).""" from typing import Any MAX_SCENARIO_CHOICES = 4 +_NODE_GRID_COLUMNS = 4 +_NODE_GRID_STEP_X = 220 +_NODE_GRID_STEP_Y = 160 +_NODE_GRID_MARGIN = 40 DEFAULT_SCENARIO_CONFIG: dict[str, Any] = { "theme_color": "#ff5f2e", @@ -48,7 +59,22 @@ def _sanitize_scenario_choice(raw: Any) -> dict[str, Any] | None: return {"text": text, "target_id": target_id if isinstance(target_id, str) and target_id else None} -def _sanitize_scenario_node(raw: Any) -> dict[str, Any] | None: +def _scenario_node_fallback_position(index: int) -> tuple[float, float]: + """Position par défaut d'un nœud dont x/y est manquant/invalide — un + quadrillage en cascade dérivé de son INDEX dans la liste, jamais + (0, 0) pour tous les nœuds (qui les empilerait exactement au même + endroit, rendant le graphe visuel illisible à la première ouverture + d'un scénario créé avant l'ajout de x/y au modèle, ou d'un nœud + ajouté par un client qui n'enverrait pas encore de position).""" + col = index % _NODE_GRID_COLUMNS + row = index // _NODE_GRID_COLUMNS + return ( + _NODE_GRID_MARGIN + col * _NODE_GRID_STEP_X, + _NODE_GRID_MARGIN + row * _NODE_GRID_STEP_Y, + ) + + +def _sanitize_scenario_node(raw: Any, index: int) -> dict[str, Any] | None: """None si le nœud est invalide (id absent, ou texte vide) — même convention que les autres sanitize_X de ce module. Un nœud SANS choix est parfaitement valide (fin de branche), contrairement à l'ancien @@ -67,7 +93,11 @@ def _sanitize_scenario_node(raw: Any) -> dict[str, Any] | None: if isinstance(raw_choices, list): choices = [c for c in (_sanitize_scenario_choice(item) for item in raw_choices) if c is not None] choices = choices[:MAX_SCENARIO_CHOICES] - return {"id": node_id, "text": text, "choices": choices} + fallback_x, fallback_y = _scenario_node_fallback_position(index) + raw_x, raw_y = raw.get("x"), raw.get("y") + x = raw_x if isinstance(raw_x, (int, float)) and not isinstance(raw_x, bool) else fallback_x + y = raw_y if isinstance(raw_y, (int, float)) and not isinstance(raw_y, bool) else fallback_y + return {"id": node_id, "text": text, "x": x, "y": y, "choices": choices} def _sanitize_scenario(raw: Any) -> dict[str, Any] | None: @@ -87,10 +117,10 @@ def _sanitize_scenario(raw: Any) -> dict[str, Any] | None: raw_nodes = raw.get("nodes") if not isinstance(raw_nodes, list): return None - nodes = [] + nodes: list[dict[str, Any]] = [] seen_ids: set[str] = set() for item in raw_nodes: - node = _sanitize_scenario_node(item) + node = _sanitize_scenario_node(item, len(nodes)) if node is None or node["id"] in seen_ids: continue seen_ids.add(node["id"]) diff --git a/document_engine/rendering/rendering.md b/document_engine/rendering/rendering.md index 527b8c37..be288ef2 100644 --- a/document_engine/rendering/rendering.md +++ b/document_engine/rendering/rendering.md @@ -130,7 +130,16 @@ regroupement à chaque appel. dans une MODALE dédiée depuis le panneau Propriétés (voir `forgeDocOpenScenarioTreeModal`, static/document/js/document-editor.js) — trop de structure (nœuds + choix + destinations) pour la colonne - étroite du panneau Propriétés, contrairement aux autres mini-jeux. + étroite du panneau Propriétés, contrairement aux autres mini-jeux. La + modale est un VRAI graphe visuel (retour utilisateur du 21/09/2026) : + chaque nœud a une position `x`/`y` (voir `scenario_config.py`), + affiché comme une carte déplaçable à la souris sur un canevas ; chaque + choix relié à un `target_id` est dessiné comme une flèche SVG + étiquetée par son texte, jamais un simple menu déroulant. Éditer le + texte/les choix d'un nœud se fait dans l'inspecteur (panneau de + droite) du nœud sélectionné ; relier un choix se fait en cliquant + "Relier" puis le nœud cible sur le graphe (mode connexion, Échap + annule). - **Autres mini-jeux** (`zones`) : carte placeholder portant le libellé du type (voir `document_engine/labels/element_kind_labels.py`) — emplacement réservé, formulaire de contenu dédié hors périmètre de diff --git a/static/document/document-editor.css b/static/document/document-editor.css index b5f670ed..d3a27549 100644 --- a/static/document/document-editor.css +++ b/static/document/document-editor.css @@ -1469,6 +1469,220 @@ img.docImage { justify-content: center; } +/* ---- Scénario : éditeur d'arbre en GRAPHE VISUEL (retour utilisateur + du 21/09/2026 : "passons à un véritable graphe visuel", remplace la + première version en liste de nœuds + menus déroulants). Dialogue bien + plus grand que la modale générique par défaut : un graphe a besoin de + place pour être lisible. ---- */ +#docScenarioTreeModal .docModalDialog { + width: min(1180px, 95vw); + max-height: min(88vh, 820px); +} + +#docScenarioTreeModal .docModalBody { + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; + min-height: 0; +} + +.docScenarioGraphTitleRow { + padding: 16px 20px; + border-bottom: 1px solid var(--doc-border); +} + +.docScenarioGraphTitleRow input { + width: 100%; + border: 1px solid var(--doc-border); + border-radius: 8px; + padding: 8px 10px; + font-size: 14px; + font-weight: 700; + color: var(--doc-text); + background: var(--doc-bg-2); +} + +.docScenarioGraphToolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 20px; + border-bottom: 1px solid var(--doc-border); +} + +.docScenarioGraphHint { + font-size: 12.5px; + color: var(--doc-muted); +} + +.docScenarioGraphHint.is-connecting { + color: var(--doc-accent); + font-weight: 600; +} + +.docScenarioGraphSplit { + flex: 1 1 auto; + min-height: 0; + display: flex; +} + +.docScenarioGraphCanvas { + position: relative; + flex: 1 1 auto; + min-width: 0; + overflow: auto; + background: + radial-gradient(circle, var(--doc-border) 1px, transparent 1px) 0 0 / 24px 24px, + var(--doc-bg-2); + cursor: default; +} + +.docScenarioGraphCanvas.is-connecting { + cursor: crosshair; +} + +.docScenarioGraphCanvas.is-connecting .docScenarioGraphNode { + cursor: crosshair; +} + +.docScenarioGraphInner { + position: relative; + width: 2200px; + height: 1600px; +} + +.docScenarioGraphSvg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.docScenarioGraphEdge { + fill: none; + stroke: var(--doc-muted); + stroke-width: 2; +} + +.docScenarioGraphEdgeLabel { + position: absolute; + transform: translate(-50%, -50%); + background: var(--doc-bg-2); + border: 1px solid var(--doc-border); + border-radius: 6px; + padding: 2px 7px; + font-size: 11px; + font-weight: 600; + color: var(--doc-muted); + white-space: nowrap; + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; +} + +.docScenarioGraphNode { + position: absolute; + width: 180px; + border: 1px solid var(--doc-border); + border-radius: 10px; + background: var(--doc-card); + padding: 10px 12px; + cursor: grab; + user-select: none; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); +} + +.docScenarioGraphNode:active { + cursor: grabbing; +} + +.docScenarioGraphNode.is-root { + border-color: var(--doc-accent); +} + +.docScenarioGraphNode.is-selected { + border-color: var(--doc-accent); + box-shadow: 0 0 0 2px var(--doc-accent); +} + +.docScenarioGraphNode.is-connect-target:hover { + border-color: var(--doc-quiz-success-border); + box-shadow: 0 0 0 2px var(--doc-quiz-success-border); +} + +.docScenarioGraphNodeLabel { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + color: var(--doc-accent); + margin-bottom: 4px; +} + +.docScenarioGraphNodeText { + font-size: 12.5px; + line-height: 1.35; + color: var(--doc-text); + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + overflow: hidden; +} + +.docScenarioGraphInspector { + flex: 0 0 300px; + border-left: 1px solid var(--doc-border); + padding: 16px; + overflow-y: auto; +} + +.docScenarioGraphInspectorEmpty { + font-size: 13px; + color: var(--doc-muted); + text-align: center; + padding: 30px 10px; +} + +.docScenarioChoiceTargetRow { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; +} + +.docScenarioChoiceTargetLabel { + flex: 1; + min-width: 0; + font-size: 12px; + color: var(--doc-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.docScenarioChoiceLinkBtn { + border: 1px solid var(--doc-border); + background: transparent; + color: var(--doc-text); + border-radius: 6px; + padding: 3px 8px; + font-size: 11.5px; + cursor: pointer; + white-space: nowrap; +} + +.docScenarioChoiceLinkBtn:hover { + border-color: var(--doc-accent); +} + +.docScenarioChoiceLinkBtn.is-active { + border-color: var(--doc-accent); + background: var(--doc-accent); + color: #14100c; +} + /* ===== RESPONSIVE (chrome de l'éditeur lui-même, <900px) ===== */ @media (width <= 900px) { .docCrumbDim { diff --git a/static/document/js/document-editor.js b/static/document/js/document-editor.js index 2c6c90f3..d6f61220 100644 --- a/static/document/js/document-editor.js +++ b/static/document/js/document-editor.js @@ -1873,12 +1873,16 @@ function forgeDocScenarioNewScenario() { // Mots mêlés. return { title: 'Nouveau scénario', - nodes: [{ id: 'n1', text: 'Nouvelle situation', choices: [] }], + nodes: [{ + id: 'n1', text: 'Nouvelle situation', x: 40, y: 40, choices: [], + }], }; } -function forgeDocScenarioNewNode(id) { - return { id, text: 'Nouveau nœud', choices: [] }; +function forgeDocScenarioNewNode(id, x, y) { + return { + id, text: 'Nouveau nœud', x, y, choices: [], + }; } function forgeDocScenarioGenerateNodeId(nodes) { @@ -1953,176 +1957,463 @@ function forgeDocRenderScenarioProps(panel, el) { } /* --------------------------------------------------------------------- - * Scénario — modale de construction de l'arbre : une carte par nœud - * (texte + liste de choix), chaque choix a un menu déroulant "mène à" - * listant les autres nœuds du même scénario (ou "— Fin de branche —"). - * La situation initiale (nodes[0]) n'est jamais supprimable depuis ici. - * Persistance identique aux autres panneaux : chaque modification - * envoie l'état COMPLET du scénario au serveur (voir patch ci-dessous), - * jamais un état simulé côté client qui pourrait diverger de ce que - * sanitize_scenario_config a réellement accepté. + * Scénario — modale de construction de l'arbre : un VRAI graphe visuel + * (retour utilisateur du 21/09/2026 : "passons à un véritable graphe + * visuel", remplace la première version en liste de nœuds + menus + * déroulants). Les nœuds sont des cartes positionnées/déplaçables à la + * souris (glisser, comme les formes libres du canevas — voir + * forgeDocBindShapeDrag) ; les choix qui ont une destination sont + * dessinés comme des flèches SVG reliant les deux nœuds, étiquetées par + * le texte du choix. Sélectionner un nœud (clic) ouvre son formulaire + * d'édition (texte + choix) dans l'inspecteur (colonne de droite) — + * relier un choix à un autre nœud se fait en cliquant "Relier" puis en + * cliquant le nœud cible sur le graphe (mode "connexion", Échap annule). + * La situation initiale (nodes[0]) reste déplaçable mais jamais + * supprimable. Persistance identique aux autres panneaux : chaque + * modification envoie l'état COMPLET du scénario au serveur (voir + * forgeDocScenarioGraphPatch), jamais un état simulé côté client qui + * pourrait diverger de ce que sanitize_scenario_config a réellement + * accepté — SAUF la position pendant un glisser, mise à jour localement + * en continu pour rester fluide, et persistée en un seul appel au + * relâchement de la souris (même principe que forgeDocBindShapeDrag). * ------------------------------------------------------------------- */ -function forgeDocRenderScenarioNodeHtml(node, nIndex, allNodes, isRoot) { - const choicesHtml = node.choices.map((choice, cIndex) => ` -
-
- - -
-
- - -
-
- `).join(''); +let forgeDocScenarioGraphState = null; +let forgeDocScenarioGraphDragBound = false; - return ` -
-
- ${isRoot ? 'Situation initiale' : `Nœud ${nIndex + 1}`} - ${isRoot ? '' : ``} -
-
- -
-
Choix
-
${choicesHtml || '
Aucun choix — fin de cette branche.
'}
- -
- `; +function forgeDocScenarioGraphNextPosition(nodes) { + // Même formule que _scenario_node_fallback_position côté serveur + // (document_engine/labels/scenario_config.py) — pas requis que ça + // matche exactement puisque le round-trip serveur fait foi de toute + // façon, mais évite un nœud visiblement mal placé le temps de la + // réponse. + const cols = 4; + const stepX = 220; + const stepY = 160; + const margin = 40; + const index = nodes.length; + return { x: margin + (index % cols) * stepX, y: margin + Math.floor(index / cols) * stepY }; } -function forgeDocRenderScenarioTreeModal(elementId, sIndex) { - const el = window.forgeDocState.elementsById[elementId]; - const scenario = el && (el.attributes.scenarios || [])[sIndex]; - if (!scenario) { - forgeDocCloseScenarioTreeModal(); +function forgeDocScenarioNodeLabel(nIndex) { + return nIndex === 0 ? 'Départ' : `Nœud ${nIndex + 1}`; +} + +function forgeDocScenarioGraphPatch(partial) { + const gs = forgeDocScenarioGraphState; + if (!gs) return; + const el = window.forgeDocState.elementsById[gs.elementId]; + const scenarios = el.attributes.scenarios.map((s, i) => (i === gs.sIndex ? { ...s, ...partial } : s)); + forgeDocUpdateAttributes(gs.elementId, { ...el.attributes, scenarios }).then(() => { + forgeDocRenderScenarioTreeModal(); + // Le panneau Propriétés affiche le nombre de nœuds par carte : le + // tenir synchronisé pendant que la modale reste ouverte. + if (window.forgeDocState.selectedId === gs.elementId) { + forgeDocRenderProps(window.forgeDocState.elementsById[gs.elementId]); + } + }); +} + +function forgeDocScenarioGraphNodeRect(nIndex) { + const gs = forgeDocScenarioGraphState; + const nodeEl = gs.nodeEls[nIndex]; + if (!nodeEl) return null; + return { + x: nodeEl.offsetLeft, y: nodeEl.offsetTop, width: nodeEl.offsetWidth, height: nodeEl.offsetHeight, + }; +} + +function forgeDocScenarioGraphRedrawEdges() { + const gs = forgeDocScenarioGraphState; + const { svgEl, innerEl } = gs; + svgEl.innerHTML = ''; + innerEl.querySelectorAll('.docScenarioGraphEdgeLabel').forEach((n) => n.remove()); + + const svgNs = 'http://www.w3.org/2000/svg'; + const defs = document.createElementNS(svgNs, 'defs'); + const marker = document.createElementNS(svgNs, 'marker'); + marker.setAttribute('id', 'docScenarioArrow'); + marker.setAttribute('viewBox', '0 0 10 10'); + marker.setAttribute('refX', '9'); + marker.setAttribute('refY', '5'); + marker.setAttribute('markerWidth', '7'); + marker.setAttribute('markerHeight', '7'); + marker.setAttribute('orient', 'auto-start-reverse'); + const arrowPath = document.createElementNS(svgNs, 'path'); + arrowPath.setAttribute('d', 'M0,0 L10,5 L0,10 z'); + arrowPath.setAttribute('fill', 'var(--doc-muted)'); + marker.appendChild(arrowPath); + defs.appendChild(marker); + svgEl.appendChild(defs); + + gs.scenario.nodes.forEach((node, nIndex) => { + const fromRect = forgeDocScenarioGraphNodeRect(nIndex); + if (!fromRect) return; + node.choices.forEach((choice) => { + if (!choice.target_id) return; + const targetIndex = gs.scenario.nodes.findIndex((n) => n.id === choice.target_id); + if (targetIndex === -1) return; + const toRect = forgeDocScenarioGraphNodeRect(targetIndex); + if (!toRect) return; + const x1 = fromRect.x + fromRect.width / 2; + const y1 = fromRect.y + fromRect.height / 2; + const x2 = toRect.x + toRect.width / 2; + const y2 = toRect.y + toRect.height / 2; + const line = document.createElementNS(svgNs, 'line'); + line.setAttribute('class', 'docScenarioGraphEdge'); + line.setAttribute('x1', String(x1)); + line.setAttribute('y1', String(y1)); + line.setAttribute('x2', String(x2)); + line.setAttribute('y2', String(y2)); + line.setAttribute('marker-end', 'url(#docScenarioArrow)'); + svgEl.appendChild(line); + + const label = document.createElement('div'); + label.className = 'docScenarioGraphEdgeLabel'; + label.style.left = `${(x1 + x2) / 2}px`; + label.style.top = `${(y1 + y2) / 2}px`; + label.textContent = choice.text; + innerEl.appendChild(label); + }); + }); +} + +function forgeDocScenarioGraphUpdateHint() { + const gs = forgeDocScenarioGraphState; + const hintEl = document.getElementById('docScenarioGraphHint'); + if (!hintEl) return; + if (gs.connecting) { + hintEl.textContent = 'Cliquez sur le nœud cible pour relier ce choix, ou appuyez sur Échap pour annuler.'; + hintEl.classList.add('is-connecting'); + gs.canvasEl.classList.add('is-connecting'); + } else { + hintEl.textContent = 'Glissez un nœud pour le déplacer. Cliquez un nœud pour modifier son texte et ses choix.'; + hintEl.classList.remove('is-connecting'); + gs.canvasEl.classList.remove('is-connecting'); + } +} + +function forgeDocScenarioGraphSelectNode(nIndex) { + const gs = forgeDocScenarioGraphState; + gs.selectedNodeIndex = nIndex; + Object.values(gs.nodeEls).forEach((el) => el.classList.remove('is-selected')); + if (gs.nodeEls[nIndex]) gs.nodeEls[nIndex].classList.add('is-selected'); + forgeDocScenarioGraphRenderInspector(); +} + +function forgeDocScenarioGraphCancelConnect() { + const gs = forgeDocScenarioGraphState; + gs.connecting = null; + Object.values(gs.nodeEls).forEach((el) => el.classList.remove('is-connect-target')); + forgeDocScenarioGraphUpdateHint(); + forgeDocScenarioGraphRenderInspector(); +} + +function forgeDocScenarioGraphStartConnect(nIndex, cIndex) { + const gs = forgeDocScenarioGraphState; + gs.connecting = { nodeIndex: nIndex, choiceIndex: cIndex }; + Object.entries(gs.nodeEls).forEach(([i, el]) => { + el.classList.toggle('is-connect-target', Number(i) !== nIndex); + }); + forgeDocScenarioGraphUpdateHint(); + forgeDocScenarioGraphRenderInspector(); +} + +function forgeDocScenarioGraphCompleteConnect(targetNodeIndex) { + const gs = forgeDocScenarioGraphState; + const { nodeIndex, choiceIndex } = gs.connecting; + if (targetNodeIndex === nodeIndex) { + forgeDocScenarioGraphCancelConnect(); return; } - const body = document.getElementById('docScenarioTreeModalBody'); - document.getElementById('docScenarioTreeModalTitle').textContent = `Arbre — ${scenario.title}`; + const targetId = gs.scenario.nodes[targetNodeIndex].id; + gs.connecting = null; + const nodes = gs.scenario.nodes.map((n, i) => ( + i === nodeIndex + ? { ...n, choices: n.choices.map((c, ci) => (ci === choiceIndex ? { ...c, target_id: targetId } : c)) } + : n + )); + forgeDocScenarioGraphPatch({ nodes }); +} - function patch(partial) { - const scenarios = el.attributes.scenarios.map((s, i) => (i === sIndex ? { ...s, ...partial } : s)); - forgeDocUpdateAttributes(elementId, { ...el.attributes, scenarios }).then(() => { - forgeDocRenderScenarioTreeModal(elementId, sIndex); - // Le panneau Propriétés affiche le nombre de nœuds par carte : - // le tenir synchronisé pendant que la modale reste ouverte. - if (window.forgeDocState.selectedId === elementId) { - forgeDocRenderProps(window.forgeDocState.elementsById[elementId]); - } - }); +function forgeDocScenarioGraphNodeMouseDown(e, nIndex) { + e.preventDefault(); + e.stopPropagation(); + const gs = forgeDocScenarioGraphState; + if (!gs) return; + if (gs.connecting) { + forgeDocScenarioGraphCompleteConnect(nIndex); + return; } + forgeDocScenarioGraphSelectNode(nIndex); + const node = gs.scenario.nodes[nIndex]; + gs.drag = { + nIndex, startX: e.clientX, startY: e.clientY, origX: node.x, origY: node.y, lastX: node.x, lastY: node.y, + }; +} - body.innerHTML = ` -
- - +function forgeDocScenarioGraphBindGlobalDrag() { + // Un SEUL listener document-level pour toute la page (même raison que + // forgeDocMotsBindGlobalMouseup) : la modale peut se ré-ouvrir + // plusieurs fois par session sans jamais accumuler de listeners. + if (forgeDocScenarioGraphDragBound) return; + forgeDocScenarioGraphDragBound = true; + document.addEventListener('mousemove', (e) => { + const gs = forgeDocScenarioGraphState; + if (!gs || !gs.drag) return; + const dx = e.clientX - gs.drag.startX; + const dy = e.clientY - gs.drag.startY; + const newX = Math.max(0, gs.drag.origX + dx); + const newY = Math.max(0, gs.drag.origY + dy); + gs.drag.lastX = newX; + gs.drag.lastY = newY; + const nodeEl = gs.nodeEls[gs.drag.nIndex]; + if (nodeEl) { + nodeEl.style.left = `${newX}px`; + nodeEl.style.top = `${newY}px`; + } + forgeDocScenarioGraphRedrawEdges(); + }); + document.addEventListener('mouseup', () => { + const gs = forgeDocScenarioGraphState; + if (!gs || !gs.drag) return; + const { + nIndex, lastX, lastY, origX, origY, + } = gs.drag; + gs.drag = null; + if (lastX === origX && lastY === origY) return; + const nodes = gs.scenario.nodes.map((n, i) => (i === nIndex ? { ...n, x: lastX, y: lastY } : n)); + forgeDocScenarioGraphPatch({ nodes }); + }); +} + +function forgeDocScenarioGraphBuildNodeEl(node, nIndex, gs) { + const div = document.createElement('div'); + div.className = 'docScenarioGraphNode'; + if (nIndex === 0) div.classList.add('is-root'); + if (nIndex === gs.selectedNodeIndex) div.classList.add('is-selected'); + if (gs.connecting && gs.connecting.nodeIndex !== nIndex) div.classList.add('is-connect-target'); + div.style.left = `${node.x}px`; + div.style.top = `${node.y}px`; + div.dataset.nodeIndex = String(nIndex); + + const labelEl = document.createElement('div'); + labelEl.className = 'docScenarioGraphNodeLabel'; + labelEl.textContent = forgeDocScenarioNodeLabel(nIndex); + const textEl = document.createElement('div'); + textEl.className = 'docScenarioGraphNodeText'; + textEl.textContent = node.text; + div.appendChild(labelEl); + div.appendChild(textEl); + + div.addEventListener('mousedown', (e) => forgeDocScenarioGraphNodeMouseDown(e, nIndex)); + return div; +} + +function forgeDocScenarioGraphRenderInspector() { + const gs = forgeDocScenarioGraphState; + const inspector = document.getElementById('docScenarioGraphInspector'); + if (!inspector) return; + const nIndex = gs.selectedNodeIndex; + const node = gs.scenario.nodes[nIndex]; + if (!node) { + inspector.innerHTML = '
Sélectionnez un nœud sur le graphe.
'; + return; + } + const isRoot = nIndex === 0; + const choicesHtml = node.choices.map((choice, cIndex) => { + const targetIndex = choice.target_id ? gs.scenario.nodes.findIndex((n) => n.id === choice.target_id) : -1; + const isConnectingThis = !!gs.connecting + && gs.connecting.nodeIndex === nIndex && gs.connecting.choiceIndex === cIndex; + return ` +
+
+ + +
+
+ ${targetIndex === -1 ? '— Fin de branche —' : `→ ${forgeDocEscapeHtml(forgeDocScenarioNodeLabel(targetIndex))}`} + + ${targetIndex === -1 ? '' : ``} +
+
+ `; + }).join(''); + + inspector.innerHTML = ` +
+ ${forgeDocScenarioNodeLabel(nIndex)} + ${isRoot ? '' : ''}
-
${scenario.nodes.map((n, i) => forgeDocRenderScenarioNodeHtml(n, i, scenario.nodes, i === 0)).join('')}
- +
+ +
+
Choix
+
${choicesHtml || '
Aucun choix — fin de cette branche.
'}
+ `; - document.getElementById('docScenarioTreeTitleInput').addEventListener('change', (e) => patch({ title: e.target.value })); - - body.querySelector('.docScenarioTreeAddNodeBtn').addEventListener('click', () => { - const newId = forgeDocScenarioGenerateNodeId(scenario.nodes); - patch({ nodes: [...scenario.nodes, forgeDocScenarioNewNode(newId)] }); - }); - - body.querySelectorAll('.docScenarioTreeNodeRemoveBtn').forEach((btn) => { - btn.addEventListener('click', () => { - const nIndex = Number(btn.dataset.nodeIndex); - const removedId = scenario.nodes[nIndex].id; + const removeBtn = document.getElementById('docScenarioTreeNodeRemoveBtn'); + if (removeBtn) { + removeBtn.addEventListener('click', () => { + const removedId = node.id; // Un choix qui pointait vers ce nœud devient "fin de branche" // (jamais une référence morte) — même filet de sécurité que // sanitize_scenario_config côté serveur. - const nodes = scenario.nodes + const nodes = gs.scenario.nodes .filter((_, i) => i !== nIndex) .map((n) => ({ ...n, choices: n.choices.map((c) => (c.target_id === removedId ? { ...c, target_id: null } : c)), })); - patch({ nodes }); + gs.selectedNodeIndex = 0; + forgeDocScenarioGraphPatch({ nodes }); }); + } + + document.getElementById('docScenarioTreeTextInput').addEventListener('change', (e) => { + const nodes = gs.scenario.nodes.map((n, i) => (i === nIndex ? { ...n, text: e.target.value } : n)); + forgeDocScenarioGraphPatch({ nodes }); }); - body.querySelectorAll('.docScenarioTreeTextInput').forEach((input) => { + document.getElementById('docScenarioTreeAddChoiceBtn').addEventListener('click', () => { + const nodes = gs.scenario.nodes.map((n, i) => ( + i === nIndex ? { ...n, choices: [...n.choices, { text: 'Nouveau choix', target_id: null }] } : n + )); + forgeDocScenarioGraphPatch({ nodes }); + }); + + inspector.querySelectorAll('.docScenarioTreeChoiceInput').forEach((input) => { input.addEventListener('change', (e) => { - const nIndex = Number(input.dataset.nodeIndex); - patch({ nodes: scenario.nodes.map((n, i) => (i === nIndex ? { ...n, text: e.target.value } : n)) }); - }); - }); - - body.querySelectorAll('.docScenarioTreeAddChoiceBtn').forEach((btn) => { - btn.addEventListener('click', () => { - const nIndex = Number(btn.dataset.nodeIndex); - patch({ - nodes: scenario.nodes.map((n, i) => ( - i === nIndex ? { ...n, choices: [...n.choices, { text: 'Nouveau choix', target_id: null }] } : n - )), - }); - }); - }); - - body.querySelectorAll('.docScenarioTreeChoiceRemoveBtn').forEach((btn) => { - btn.addEventListener('click', () => { - const nIndex = Number(btn.dataset.nodeIndex); - const cIndex = Number(btn.dataset.choiceIndex); - patch({ - nodes: scenario.nodes.map((n, i) => ( - i === nIndex ? { ...n, choices: n.choices.filter((_, ci) => ci !== cIndex) } : n - )), - }); - }); - }); - - body.querySelectorAll('.docScenarioTreeChoiceInput').forEach((input) => { - input.addEventListener('change', (e) => { - const nIndex = Number(input.dataset.nodeIndex); const cIndex = Number(input.dataset.choiceIndex); - patch({ - nodes: scenario.nodes.map((n, i) => ( - i === nIndex - ? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, text: e.target.value } : c)) } - : n - )), - }); + const nodes = gs.scenario.nodes.map((n, i) => ( + i === nIndex + ? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, text: e.target.value } : c)) } + : n + )); + forgeDocScenarioGraphPatch({ nodes }); }); }); - body.querySelectorAll('.docScenarioTreeTargetSelect').forEach((select) => { - select.addEventListener('change', (e) => { - const nIndex = Number(select.dataset.nodeIndex); - const cIndex = Number(select.dataset.choiceIndex); - const targetId = e.target.value || null; - patch({ - nodes: scenario.nodes.map((n, i) => ( - i === nIndex - ? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, target_id: targetId } : c)) } - : n - )), - }); + inspector.querySelectorAll('.docScenarioTreeChoiceRemoveBtn').forEach((btn) => { + btn.addEventListener('click', () => { + const cIndex = Number(btn.dataset.choiceIndex); + const nodes = gs.scenario.nodes.map((n, i) => ( + i === nIndex ? { ...n, choices: n.choices.filter((_, ci) => ci !== cIndex) } : n + )); + forgeDocScenarioGraphPatch({ nodes }); }); }); + + inspector.querySelectorAll('.docScenarioChoiceLinkBtn[data-action="link"]').forEach((btn) => { + btn.addEventListener('click', () => { + const cIndex = Number(btn.dataset.choiceIndex); + if (gs.connecting && gs.connecting.nodeIndex === nIndex && gs.connecting.choiceIndex === cIndex) { + forgeDocScenarioGraphCancelConnect(); + } else { + forgeDocScenarioGraphStartConnect(nIndex, cIndex); + } + }); + }); + + inspector.querySelectorAll('.docScenarioChoiceLinkBtn[data-action="unlink"]').forEach((btn) => { + btn.addEventListener('click', () => { + const cIndex = Number(btn.dataset.choiceIndex); + const nodes = gs.scenario.nodes.map((n, i) => ( + i === nIndex + ? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, target_id: null } : c)) } + : n + )); + forgeDocScenarioGraphPatch({ nodes }); + }); + }); +} + +function forgeDocRenderScenarioTreeModal() { + const gs = forgeDocScenarioGraphState; + if (!gs) return; + const el = window.forgeDocState.elementsById[gs.elementId]; + const scenario = el && (el.attributes.scenarios || [])[gs.sIndex]; + if (!scenario) { + forgeDocCloseScenarioTreeModal(); + return; + } + gs.scenario = scenario; + if (gs.selectedNodeIndex >= scenario.nodes.length) gs.selectedNodeIndex = 0; + if (gs.connecting && gs.connecting.nodeIndex >= scenario.nodes.length) gs.connecting = null; + + document.getElementById('docScenarioTreeModalTitle').textContent = `Arbre — ${scenario.title}`; + const body = document.getElementById('docScenarioTreeModalBody'); + body.innerHTML = ` +
+ +
+
+ + +
+
+
+
+ +
+
+
+
+ `; + + document.getElementById('docScenarioTreeTitleInput').addEventListener('change', (e) => { + forgeDocScenarioGraphPatch({ title: e.target.value }); + }); + + document.getElementById('docScenarioGraphAddNode').addEventListener('click', () => { + const newId = forgeDocScenarioGenerateNodeId(scenario.nodes); + const pos = forgeDocScenarioGraphNextPosition(scenario.nodes); + gs.selectedNodeIndex = scenario.nodes.length; + forgeDocScenarioGraphPatch({ nodes: [...scenario.nodes, forgeDocScenarioNewNode(newId, pos.x, pos.y)] }); + }); + + gs.canvasEl = document.getElementById('docScenarioGraphCanvas'); + gs.innerEl = document.getElementById('docScenarioGraphInner'); + gs.svgEl = document.getElementById('docScenarioGraphSvg'); + gs.nodeEls = {}; + + scenario.nodes.forEach((node, nIndex) => { + const nodeEl = forgeDocScenarioGraphBuildNodeEl(node, nIndex, gs); + gs.innerEl.appendChild(nodeEl); + gs.nodeEls[nIndex] = nodeEl; + }); + + gs.canvasEl.addEventListener('click', (e) => { + if (gs.connecting && (e.target === gs.canvasEl || e.target === gs.innerEl)) { + forgeDocScenarioGraphCancelConnect(); + } + }); + + forgeDocScenarioGraphRedrawEdges(); + forgeDocScenarioGraphUpdateHint(); + forgeDocScenarioGraphRenderInspector(); } function forgeDocOpenScenarioTreeModal(elementId, sIndex) { document.getElementById('docScenarioTreeModal').classList.add('is-open'); - forgeDocRenderScenarioTreeModal(elementId, sIndex); + forgeDocScenarioGraphState = { + elementId, sIndex, selectedNodeIndex: 0, connecting: null, drag: null, nodeEls: {}, + }; + forgeDocScenarioGraphBindGlobalDrag(); + forgeDocRenderScenarioTreeModal(); } function forgeDocCloseScenarioTreeModal() { document.getElementById('docScenarioTreeModal').classList.remove('is-open'); + forgeDocScenarioGraphState = null; } function forgeDocBindScenarioTreeModal() { @@ -2131,7 +2422,13 @@ function forgeDocBindScenarioTreeModal() { if (e.target.id === 'docScenarioTreeModal') forgeDocCloseScenarioTreeModal(); }); document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') forgeDocCloseScenarioTreeModal(); + if (e.key !== 'Escape') return; + const gs = forgeDocScenarioGraphState; + if (gs && gs.connecting) { + forgeDocScenarioGraphCancelConnect(); + return; + } + forgeDocCloseScenarioTreeModal(); }); } diff --git a/tests/document/test_scenario_config.py b/tests/document/test_scenario_config.py index e54ba4f4..71c793fe 100644 --- a/tests/document/test_scenario_config.py +++ b/tests/document/test_scenario_config.py @@ -1,7 +1,7 @@ """Modèle de données du mini-jeu Scénario (document_engine/labels/scenario_config.py) — sans Flask, teste -directement sanitize_scenario_config (arbre de décision), et le rendu -du plateau.""" +directement sanitize_scenario_config (arbre de décision, graphe visuel +avec positions x/y), et le rendu du plateau.""" from typing import Any @@ -22,12 +22,14 @@ def test_sanitize_scenario_config_keeps_a_valid_tree_as_is() -> None: { "id": "n1", "text": "Un collègue vous demande son mot de passe.", + "x": 40, + "y": 60, "choices": [ {"text": "Le lui donner", "target_id": "n2"}, {"text": "Refuser", "target_id": None}, ], }, - {"id": "n2", "text": "Violation de sécurité.", "choices": []}, + {"id": "n2", "text": "Violation de sécurité.", "x": 300, "y": 60, "choices": []}, ], } ], @@ -37,6 +39,64 @@ def test_sanitize_scenario_config_keeps_a_valid_tree_as_is() -> None: assert config["scenarios"] == raw["scenarios"] +def test_sanitize_scenario_config_keeps_explicit_xy_position() -> None: + raw = { + "scenarios": [ + {"title": "Titre", "nodes": [{"id": "n1", "text": "Situation", "x": 500, "y": 300, "choices": []}]} + ] + } + config = doc_engine.sanitize_scenario_config(raw) + node = config["scenarios"][0]["nodes"][0] + assert (node["x"], node["y"]) == (500, 300) + + +def test_sanitize_scenario_config_falls_back_to_a_cascading_grid_when_xy_is_missing() -> None: + """Jamais (0, 0) pour tous les nœuds (qui les empilerait exactement au + même endroit sur le graphe visuel) : une position par défaut dérivée + de l'INDEX du nœud dans la liste.""" + raw = { + "scenarios": [ + { + "title": "Titre", + "nodes": [ + {"id": "n1", "text": "A", "choices": []}, + {"id": "n2", "text": "B", "choices": []}, + ], + } + ] + } + config = doc_engine.sanitize_scenario_config(raw) + nodes = config["scenarios"][0]["nodes"] + assert (nodes[0]["x"], nodes[0]["y"]) != (nodes[1]["x"], nodes[1]["y"]) + + +def test_sanitize_scenario_config_falls_back_to_grid_when_xy_is_not_a_number() -> None: + raw = { + "scenarios": [ + {"title": "Titre", "nodes": [{"id": "n1", "text": "Situation", "x": "beaucoup", "y": None, "choices": []}]} + ] + } + config = doc_engine.sanitize_scenario_config(raw) + node = config["scenarios"][0]["nodes"][0] + assert isinstance(node["x"], (int, float)) + assert isinstance(node["y"], (int, float)) + + +def test_sanitize_scenario_config_rejects_a_boolean_as_xy() -> None: + """True/False sont des int en Python (bool hérite de int) — même + garde-fou que pour correct_index dans l'ancien modèle : un JSON + malformé pourrait glisser `true` là où une coordonnée est attendue.""" + raw = { + "scenarios": [ + {"title": "Titre", "nodes": [{"id": "n1", "text": "Situation", "x": True, "y": False, "choices": []}]} + ] + } + config = doc_engine.sanitize_scenario_config(raw) + node = config["scenarios"][0]["nodes"][0] + assert node["x"] is not True + assert node["y"] is not False + + def test_sanitize_scenario_config_drops_a_scenario_with_empty_title() -> None: raw = {"scenarios": [{"title": " ", "nodes": [{"id": "n1", "text": "Situation", "choices": []}]}]} assert doc_engine.sanitize_scenario_config(raw)["scenarios"] == []