Remplace la modale liste+menus deroulants du Scenario par un vrai graphe visuel
Build and deploy / test-python (push) Successful in 7m32s
Build and deploy / test-js (push) Successful in 49s
Build and deploy / lint-python (push) Successful in 5m21s
Build and deploy / lint-js (push) Failing after 1m12s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m53s
Build and deploy / test-python (push) Successful in 7m32s
Build and deploy / test-js (push) Successful in 49s
Build and deploy / lint-python (push) Successful in 5m21s
Build and deploy / lint-js (push) Failing after 1m12s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m53s
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7e504b7865
commit
d92f75a803
@@ -198,14 +198,17 @@ convention resolve_X/sanitize_X que `quiz_config.py`.
|
|||||||
**Structure d'un scénario** : `{"title": str, "nodes": [...]}`.
|
**Structure d'un scénario** : `{"title": str, "nodes": [...]}`.
|
||||||
`nodes[0]` est TOUJOURS la situation initiale (racine de l'arbre, jamais
|
`nodes[0]` est TOUJOURS la situation initiale (racine de l'arbre, jamais
|
||||||
supprimable depuis le panneau Propriétés — voir
|
supprimable depuis le panneau Propriétés — voir
|
||||||
`forgeDocRenderScenarioNodeHtml`, static/document/js/document-editor.js).
|
`forgeDocScenarioGraphRenderInspector`, static/document/js/
|
||||||
Chaque nœud est `{"id": str, "text": str, "choices": [...]}` — `id` est
|
document-editor.js). Chaque nœud est `{"id": str, "text": str,
|
||||||
une référence STABLE (générée côté client, jamais recalculée côté
|
"x": float, "y": float, "choices": [...]}` — `id` est une référence
|
||||||
serveur) vers laquelle un choix d'un AUTRE nœud peut pointer via son
|
STABLE (générée côté client, jamais recalculée côté serveur) vers
|
||||||
`target_id` ; un nœud SANS choix est une fin de branche (état normal,
|
laquelle un choix d'un AUTRE nœud peut pointer via son `target_id` ;
|
||||||
pas une erreur — contrairement à l'ancien modèle qui exigeait 2 à 4
|
`x`/`y` positionnent le nœud sur le canevas du graphe visuel (voir
|
||||||
choix par scénario). Un choix est `{"text": str, "target_id": str |
|
`forgeDocRenderScenarioTreeModal`) ; un nœud SANS choix est une fin de
|
||||||
None}` — `target_id` à `None` signifie "fin de branche à ce choix".
|
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`
|
### `MAX_SCENARIO_CHOICES: int`
|
||||||
Borne technique du nombre de choix par nœud (`4`) — évite une UI
|
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
|
`_sanitize_scenario_node`/`_sanitize_scenario_choice`, privées) : le
|
||||||
`title` doit être non vide, chaque nœud doit avoir un `id` (chaîne non
|
`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
|
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
|
est supprimé), et ses choix sont tronqués à `MAX_SCENARIO_CHOICES`. Un
|
||||||
scénario entier est supprimé s'il ne reste plus aucun nœud valide après
|
`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
|
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
|
des ids valides connu, tout `target_id` qui ne pointe plus vers un nœud
|
||||||
existant (nœud invalide/supprimé) est silencieusement remis à `None`
|
existant (nœud invalide/supprimé) est silencieusement remis à `None`
|
||||||
|
|||||||
@@ -16,16 +16,27 @@ jamais, et renvoie toujours un dict complet.
|
|||||||
Structure d'un scénario : {"title": str, "nodes": [...]}. `nodes[0]` est
|
Structure d'un scénario : {"title": str, "nodes": [...]}. `nodes[0]` est
|
||||||
TOUJOURS la situation initiale (racine de l'arbre, jamais supprimable
|
TOUJOURS la situation initiale (racine de l'arbre, jamais supprimable
|
||||||
depuis le panneau Propriétés). Chaque nœud est
|
depuis le panneau Propriétés). Chaque nœud est
|
||||||
{"id": str, "text": str, "choices": [...]} — `id` est une référence
|
{"id": str, "text": str, "x": float, "y": float, "choices": [...]} —
|
||||||
STABLE (générée côté client, jamais recalculée ici) vers laquelle un
|
`id` est une référence STABLE (générée côté client, jamais recalculée
|
||||||
choix d'un AUTRE nœud peut pointer via son `target_id` ; un nœud sans
|
ici) vers laquelle un choix d'un AUTRE nœud peut pointer via son
|
||||||
choix est une fin de branche. Un choix est {"text": str,
|
`target_id` ; un nœud sans choix est une fin de branche. `x`/`y`
|
||||||
"target_id": str | None} — `target_id` à None signifie "fin de branche
|
positionnent le nœud sur le canevas du graphe visuel (voir
|
||||||
à ce choix" (aucun nœud suivant)."""
|
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
|
from typing import Any
|
||||||
|
|
||||||
MAX_SCENARIO_CHOICES = 4
|
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] = {
|
DEFAULT_SCENARIO_CONFIG: dict[str, Any] = {
|
||||||
"theme_color": "#ff5f2e",
|
"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}
|
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
|
"""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
|
convention que les autres sanitize_X de ce module. Un nœud SANS choix
|
||||||
est parfaitement valide (fin de branche), contrairement à l'ancien
|
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):
|
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 = [c for c in (_sanitize_scenario_choice(item) for item in raw_choices) if c is not None]
|
||||||
choices = choices[:MAX_SCENARIO_CHOICES]
|
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:
|
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")
|
raw_nodes = raw.get("nodes")
|
||||||
if not isinstance(raw_nodes, list):
|
if not isinstance(raw_nodes, list):
|
||||||
return None
|
return None
|
||||||
nodes = []
|
nodes: list[dict[str, Any]] = []
|
||||||
seen_ids: set[str] = set()
|
seen_ids: set[str] = set()
|
||||||
for item in raw_nodes:
|
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:
|
if node is None or node["id"] in seen_ids:
|
||||||
continue
|
continue
|
||||||
seen_ids.add(node["id"])
|
seen_ids.add(node["id"])
|
||||||
|
|||||||
@@ -130,7 +130,16 @@ regroupement à chaque appel.
|
|||||||
dans une MODALE dédiée depuis le panneau Propriétés (voir
|
dans une MODALE dédiée depuis le panneau Propriétés (voir
|
||||||
`forgeDocOpenScenarioTreeModal`, static/document/js/document-editor.js)
|
`forgeDocOpenScenarioTreeModal`, static/document/js/document-editor.js)
|
||||||
— trop de structure (nœuds + choix + destinations) pour la colonne
|
— 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é
|
- **Autres mini-jeux** (`zones`) : carte placeholder portant le libellé
|
||||||
du type (voir `document_engine/labels/element_kind_labels.py`) —
|
du type (voir `document_engine/labels/element_kind_labels.py`) —
|
||||||
emplacement réservé, formulaire de contenu dédié hors périmètre de
|
emplacement réservé, formulaire de contenu dédié hors périmètre de
|
||||||
|
|||||||
@@ -1469,6 +1469,220 @@ img.docImage {
|
|||||||
justify-content: center;
|
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) ===== */
|
/* ===== RESPONSIVE (chrome de l'éditeur lui-même, <900px) ===== */
|
||||||
@media (width <= 900px) {
|
@media (width <= 900px) {
|
||||||
.docCrumbDim {
|
.docCrumbDim {
|
||||||
|
|||||||
@@ -1873,12 +1873,16 @@ function forgeDocScenarioNewScenario() {
|
|||||||
// Mots mêlés.
|
// Mots mêlés.
|
||||||
return {
|
return {
|
||||||
title: 'Nouveau scénario',
|
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) {
|
function forgeDocScenarioNewNode(id, x, y) {
|
||||||
return { id, text: 'Nouveau nœud', choices: [] };
|
return {
|
||||||
|
id, text: 'Nouveau nœud', x, y, choices: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeDocScenarioGenerateNodeId(nodes) {
|
function forgeDocScenarioGenerateNodeId(nodes) {
|
||||||
@@ -1953,176 +1957,463 @@ function forgeDocRenderScenarioProps(panel, el) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------
|
/* ---------------------------------------------------------------------
|
||||||
* Scénario — modale de construction de l'arbre : une carte par nœud
|
* Scénario — modale de construction de l'arbre : un VRAI graphe visuel
|
||||||
* (texte + liste de choix), chaque choix a un menu déroulant "mène à"
|
* (retour utilisateur du 21/09/2026 : "passons à un véritable graphe
|
||||||
* listant les autres nœuds du même scénario (ou "— Fin de branche —").
|
* visuel", remplace la première version en liste de nœuds + menus
|
||||||
* La situation initiale (nodes[0]) n'est jamais supprimable depuis ici.
|
* déroulants). Les nœuds sont des cartes positionnées/déplaçables à la
|
||||||
* Persistance identique aux autres panneaux : chaque modification
|
* souris (glisser, comme les formes libres du canevas — voir
|
||||||
* envoie l'état COMPLET du scénario au serveur (voir patch ci-dessous),
|
* forgeDocBindShapeDrag) ; les choix qui ont une destination sont
|
||||||
* jamais un état simulé côté client qui pourrait diverger de ce que
|
* dessinés comme des flèches SVG reliant les deux nœuds, étiquetées par
|
||||||
* sanitize_scenario_config a réellement accepté.
|
* 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) {
|
let forgeDocScenarioGraphState = null;
|
||||||
const choicesHtml = node.choices.map((choice, cIndex) => `
|
let forgeDocScenarioGraphDragBound = false;
|
||||||
<div class="docScenarioChoiceBlock">
|
|
||||||
<div class="docQuizChoiceRow">
|
|
||||||
<input type="text"
|
|
||||||
class="docQuizChoiceInput docScenarioTreeChoiceInput"
|
|
||||||
value="${forgeDocEscapeHtml(choice.text)}"
|
|
||||||
placeholder="Texte du choix"
|
|
||||||
data-node-index="${nIndex}"
|
|
||||||
data-choice-index="${cIndex}">
|
|
||||||
<button type="button" class="docQuizRemoveBtn docScenarioTreeChoiceRemoveBtn" data-node-index="${nIndex}" data-choice-index="${cIndex}" title="Supprimer ce choix">🗑</button>
|
|
||||||
</div>
|
|
||||||
<div class="docFieldRow">
|
|
||||||
<label>Mène à</label>
|
|
||||||
<select class="docScenarioTreeTargetSelect" data-node-index="${nIndex}" data-choice-index="${cIndex}">
|
|
||||||
<option value="">— Fin de branche —</option>
|
|
||||||
${allNodes.filter((n) => n.id !== node.id).map((n) => `<option value="${n.id}" ${choice.target_id === n.id ? 'selected' : ''}>${forgeDocEscapeHtml(n.text.slice(0, 40) || n.id)}</option>`).join('')}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
return `
|
function forgeDocScenarioGraphNextPosition(nodes) {
|
||||||
<div class="docQuizQuestion docScenarioTreeNode" data-node-index="${nIndex}">
|
// Même formule que _scenario_node_fallback_position côté serveur
|
||||||
<div class="docQuizQuestionHeader">
|
// (document_engine/labels/scenario_config.py) — pas requis que ça
|
||||||
<span class="docFieldLabel">${isRoot ? 'Situation initiale' : `Nœud ${nIndex + 1}`}</span>
|
// matche exactement puisque le round-trip serveur fait foi de toute
|
||||||
${isRoot ? '' : `<button type="button" class="docQuizRemoveBtn docScenarioTreeNodeRemoveBtn" data-node-index="${nIndex}" title="Supprimer ce nœud">🗑</button>`}
|
// façon, mais évite un nœud visiblement mal placé le temps de la
|
||||||
</div>
|
// réponse.
|
||||||
<div class="docField">
|
const cols = 4;
|
||||||
<textarea class="docScenarioTreeTextInput" rows="3" data-node-index="${nIndex}" placeholder="Texte (situation ou conséquence)">${forgeDocEscapeHtml(node.text)}</textarea>
|
const stepX = 220;
|
||||||
</div>
|
const stepY = 160;
|
||||||
<div class="docSectionLabel docQuizQuestionsLabel">Choix</div>
|
const margin = 40;
|
||||||
<div class="docQuizChoicesList">${choicesHtml || '<div class="docEmptyState">Aucun choix — fin de cette branche.</div>'}</div>
|
const index = nodes.length;
|
||||||
<button type="button" class="docBtnSecondary docScenarioTreeAddChoiceBtn" data-node-index="${nIndex}" ${node.choices.length >= 4 ? 'disabled' : ''}>+ Ajouter un choix</button>
|
return { x: margin + (index % cols) * stepX, y: margin + Math.floor(index / cols) * stepY };
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeDocRenderScenarioTreeModal(elementId, sIndex) {
|
function forgeDocScenarioNodeLabel(nIndex) {
|
||||||
const el = window.forgeDocState.elementsById[elementId];
|
return nIndex === 0 ? 'Départ' : `Nœud ${nIndex + 1}`;
|
||||||
const scenario = el && (el.attributes.scenarios || [])[sIndex];
|
}
|
||||||
if (!scenario) {
|
|
||||||
forgeDocCloseScenarioTreeModal();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const body = document.getElementById('docScenarioTreeModalBody');
|
const targetId = gs.scenario.nodes[targetNodeIndex].id;
|
||||||
document.getElementById('docScenarioTreeModalTitle').textContent = `Arbre — ${scenario.title}`;
|
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) {
|
function forgeDocScenarioGraphNodeMouseDown(e, nIndex) {
|
||||||
const scenarios = el.attributes.scenarios.map((s, i) => (i === sIndex ? { ...s, ...partial } : s));
|
e.preventDefault();
|
||||||
forgeDocUpdateAttributes(elementId, { ...el.attributes, scenarios }).then(() => {
|
e.stopPropagation();
|
||||||
forgeDocRenderScenarioTreeModal(elementId, sIndex);
|
const gs = forgeDocScenarioGraphState;
|
||||||
// Le panneau Propriétés affiche le nombre de nœuds par carte :
|
if (!gs) return;
|
||||||
// le tenir synchronisé pendant que la modale reste ouverte.
|
if (gs.connecting) {
|
||||||
if (window.forgeDocState.selectedId === elementId) {
|
forgeDocScenarioGraphCompleteConnect(nIndex);
|
||||||
forgeDocRenderProps(window.forgeDocState.elementsById[elementId]);
|
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() {
|
||||||
<div class="docField">
|
// Un SEUL listener document-level pour toute la page (même raison que
|
||||||
<label for="docScenarioTreeTitleInput">Nom du scénario</label>
|
// forgeDocMotsBindGlobalMouseup) : la modale peut se ré-ouvrir
|
||||||
<input type="text" id="docScenarioTreeTitleInput" value="${forgeDocEscapeHtml(scenario.title)}">
|
// 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 = '<div class="docScenarioGraphInspectorEmpty">Sélectionnez un nœud sur le graphe.</div>';
|
||||||
|
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 `
|
||||||
|
<div class="docScenarioChoiceBlock">
|
||||||
|
<div class="docQuizChoiceRow">
|
||||||
|
<input type="text"
|
||||||
|
class="docQuizChoiceInput docScenarioTreeChoiceInput"
|
||||||
|
value="${forgeDocEscapeHtml(choice.text)}"
|
||||||
|
placeholder="Texte du choix"
|
||||||
|
data-choice-index="${cIndex}">
|
||||||
|
<button type="button" class="docQuizRemoveBtn docScenarioTreeChoiceRemoveBtn" data-choice-index="${cIndex}" title="Supprimer ce choix">🗑</button>
|
||||||
|
</div>
|
||||||
|
<div class="docScenarioChoiceTargetRow">
|
||||||
|
<span class="docScenarioChoiceTargetLabel">${targetIndex === -1 ? '— Fin de branche —' : `→ ${forgeDocEscapeHtml(forgeDocScenarioNodeLabel(targetIndex))}`}</span>
|
||||||
|
<button type="button" class="docScenarioChoiceLinkBtn ${isConnectingThis ? 'is-active' : ''}" data-choice-index="${cIndex}" data-action="link">${isConnectingThis ? 'Cliquez un nœud…' : 'Relier'}</button>
|
||||||
|
${targetIndex === -1 ? '' : `<button type="button" class="docScenarioChoiceLinkBtn" data-choice-index="${cIndex}" data-action="unlink">Dissocier</button>`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
inspector.innerHTML = `
|
||||||
|
<div class="docQuizQuestionHeader">
|
||||||
|
<span class="docFieldLabel">${forgeDocScenarioNodeLabel(nIndex)}</span>
|
||||||
|
${isRoot ? '' : '<button type="button" class="docQuizRemoveBtn" id="docScenarioTreeNodeRemoveBtn" title="Supprimer ce nœud">🗑</button>'}
|
||||||
</div>
|
</div>
|
||||||
<div id="docScenarioTreeNodesList">${scenario.nodes.map((n, i) => forgeDocRenderScenarioNodeHtml(n, i, scenario.nodes, i === 0)).join('')}</div>
|
<div class="docField">
|
||||||
<button type="button" class="docBtnSecondary docQuizAddBtn docScenarioTreeAddNodeBtn">+ Ajouter un nœud</button>
|
<textarea id="docScenarioTreeTextInput" rows="4" placeholder="Texte (situation ou conséquence)">${forgeDocEscapeHtml(node.text)}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="docSectionLabel docQuizQuestionsLabel">Choix</div>
|
||||||
|
<div class="docQuizChoicesList">${choicesHtml || '<div class="docEmptyState">Aucun choix — fin de cette branche.</div>'}</div>
|
||||||
|
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docScenarioTreeAddChoiceBtn" ${node.choices.length >= 4 ? 'disabled' : ''}>+ Ajouter un choix</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.getElementById('docScenarioTreeTitleInput').addEventListener('change', (e) => patch({ title: e.target.value }));
|
const removeBtn = document.getElementById('docScenarioTreeNodeRemoveBtn');
|
||||||
|
if (removeBtn) {
|
||||||
body.querySelector('.docScenarioTreeAddNodeBtn').addEventListener('click', () => {
|
removeBtn.addEventListener('click', () => {
|
||||||
const newId = forgeDocScenarioGenerateNodeId(scenario.nodes);
|
const removedId = node.id;
|
||||||
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;
|
|
||||||
// Un choix qui pointait vers ce nœud devient "fin de branche"
|
// 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
|
// (jamais une référence morte) — même filet de sécurité que
|
||||||
// sanitize_scenario_config côté serveur.
|
// sanitize_scenario_config côté serveur.
|
||||||
const nodes = scenario.nodes
|
const nodes = gs.scenario.nodes
|
||||||
.filter((_, i) => i !== nIndex)
|
.filter((_, i) => i !== nIndex)
|
||||||
.map((n) => ({
|
.map((n) => ({
|
||||||
...n,
|
...n,
|
||||||
choices: n.choices.map((c) => (c.target_id === removedId ? { ...c, target_id: null } : c)),
|
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) => {
|
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);
|
const cIndex = Number(input.dataset.choiceIndex);
|
||||||
patch({
|
const nodes = gs.scenario.nodes.map((n, i) => (
|
||||||
nodes: scenario.nodes.map((n, i) => (
|
i === nIndex
|
||||||
i === nIndex
|
? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, text: e.target.value } : c)) }
|
||||||
? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, text: e.target.value } : c)) }
|
: n
|
||||||
: n
|
));
|
||||||
)),
|
forgeDocScenarioGraphPatch({ nodes });
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
body.querySelectorAll('.docScenarioTreeTargetSelect').forEach((select) => {
|
inspector.querySelectorAll('.docScenarioTreeChoiceRemoveBtn').forEach((btn) => {
|
||||||
select.addEventListener('change', (e) => {
|
btn.addEventListener('click', () => {
|
||||||
const nIndex = Number(select.dataset.nodeIndex);
|
const cIndex = Number(btn.dataset.choiceIndex);
|
||||||
const cIndex = Number(select.dataset.choiceIndex);
|
const nodes = gs.scenario.nodes.map((n, i) => (
|
||||||
const targetId = e.target.value || null;
|
i === nIndex ? { ...n, choices: n.choices.filter((_, ci) => ci !== cIndex) } : n
|
||||||
patch({
|
));
|
||||||
nodes: scenario.nodes.map((n, i) => (
|
forgeDocScenarioGraphPatch({ nodes });
|
||||||
i === nIndex
|
|
||||||
? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, target_id: targetId } : c)) }
|
|
||||||
: n
|
|
||||||
)),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="docScenarioGraphTitleRow">
|
||||||
|
<input type="text"
|
||||||
|
id="docScenarioTreeTitleInput"
|
||||||
|
value="${forgeDocEscapeHtml(scenario.title)}"
|
||||||
|
placeholder="Nom du scénario">
|
||||||
|
</div>
|
||||||
|
<div class="docScenarioGraphToolbar">
|
||||||
|
<button type="button" class="docBtnSecondary" id="docScenarioGraphAddNode">+ Ajouter un nœud</button>
|
||||||
|
<span class="docScenarioGraphHint" id="docScenarioGraphHint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="docScenarioGraphSplit">
|
||||||
|
<div class="docScenarioGraphCanvas" id="docScenarioGraphCanvas">
|
||||||
|
<div class="docScenarioGraphInner" id="docScenarioGraphInner">
|
||||||
|
<svg class="docScenarioGraphSvg" id="docScenarioGraphSvg"></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docScenarioGraphInspector" id="docScenarioGraphInspector"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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) {
|
function forgeDocOpenScenarioTreeModal(elementId, sIndex) {
|
||||||
document.getElementById('docScenarioTreeModal').classList.add('is-open');
|
document.getElementById('docScenarioTreeModal').classList.add('is-open');
|
||||||
forgeDocRenderScenarioTreeModal(elementId, sIndex);
|
forgeDocScenarioGraphState = {
|
||||||
|
elementId, sIndex, selectedNodeIndex: 0, connecting: null, drag: null, nodeEls: {},
|
||||||
|
};
|
||||||
|
forgeDocScenarioGraphBindGlobalDrag();
|
||||||
|
forgeDocRenderScenarioTreeModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeDocCloseScenarioTreeModal() {
|
function forgeDocCloseScenarioTreeModal() {
|
||||||
document.getElementById('docScenarioTreeModal').classList.remove('is-open');
|
document.getElementById('docScenarioTreeModal').classList.remove('is-open');
|
||||||
|
forgeDocScenarioGraphState = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeDocBindScenarioTreeModal() {
|
function forgeDocBindScenarioTreeModal() {
|
||||||
@@ -2131,7 +2422,13 @@ function forgeDocBindScenarioTreeModal() {
|
|||||||
if (e.target.id === 'docScenarioTreeModal') forgeDocCloseScenarioTreeModal();
|
if (e.target.id === 'docScenarioTreeModal') forgeDocCloseScenarioTreeModal();
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') forgeDocCloseScenarioTreeModal();
|
if (e.key !== 'Escape') return;
|
||||||
|
const gs = forgeDocScenarioGraphState;
|
||||||
|
if (gs && gs.connecting) {
|
||||||
|
forgeDocScenarioGraphCancelConnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
forgeDocCloseScenarioTreeModal();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Modèle de données du mini-jeu Scénario
|
"""Modèle de données du mini-jeu Scénario
|
||||||
(document_engine/labels/scenario_config.py) — sans Flask, teste
|
(document_engine/labels/scenario_config.py) — sans Flask, teste
|
||||||
directement sanitize_scenario_config (arbre de décision), et le rendu
|
directement sanitize_scenario_config (arbre de décision, graphe visuel
|
||||||
du plateau."""
|
avec positions x/y), et le rendu du plateau."""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -22,12 +22,14 @@ def test_sanitize_scenario_config_keeps_a_valid_tree_as_is() -> None:
|
|||||||
{
|
{
|
||||||
"id": "n1",
|
"id": "n1",
|
||||||
"text": "Un collègue vous demande son mot de passe.",
|
"text": "Un collègue vous demande son mot de passe.",
|
||||||
|
"x": 40,
|
||||||
|
"y": 60,
|
||||||
"choices": [
|
"choices": [
|
||||||
{"text": "Le lui donner", "target_id": "n2"},
|
{"text": "Le lui donner", "target_id": "n2"},
|
||||||
{"text": "Refuser", "target_id": None},
|
{"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"]
|
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:
|
def test_sanitize_scenario_config_drops_a_scenario_with_empty_title() -> None:
|
||||||
raw = {"scenarios": [{"title": " ", "nodes": [{"id": "n1", "text": "Situation", "choices": []}]}]}
|
raw = {"scenarios": [{"title": " ", "nodes": [{"id": "n1", "text": "Situation", "choices": []}]}]}
|
||||||
assert doc_engine.sanitize_scenario_config(raw)["scenarios"] == []
|
assert doc_engine.sanitize_scenario_config(raw)["scenarios"] == []
|
||||||
|
|||||||
Reference in New Issue
Block a user