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

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:
william
2026-09-21 09:03:47 +02:00
co-authored by Claude Sonnet 5
parent 7e504b7865
commit d92f75a803
6 changed files with 777 additions and 159 deletions
+18 -10
View File
@@ -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`
+40 -10
View File
@@ -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"])
+10 -1
View File
@@ -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
+214
View File
@@ -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 {
+432 -135
View File
@@ -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) => `
<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('');
let forgeDocScenarioGraphState = null;
let forgeDocScenarioGraphDragBound = false;
return `
<div class="docQuizQuestion docScenarioTreeNode" data-node-index="${nIndex}">
<div class="docQuizQuestionHeader">
<span class="docFieldLabel">${isRoot ? 'Situation initiale' : `Nœud ${nIndex + 1}`}</span>
${isRoot ? '' : `<button type="button" class="docQuizRemoveBtn docScenarioTreeNodeRemoveBtn" data-node-index="${nIndex}" title="Supprimer ce nœud">🗑</button>`}
</div>
<div class="docField">
<textarea class="docScenarioTreeTextInput" rows="3" data-node-index="${nIndex}" 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 docScenarioTreeAddChoiceBtn" data-node-index="${nIndex}" ${node.choices.length >= 4 ? 'disabled' : ''}>+ Ajouter un choix</button>
</div>
`;
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 = `
<div class="docField">
<label for="docScenarioTreeTitleInput">Nom du scénario</label>
<input type="text" id="docScenarioTreeTitleInput" value="${forgeDocEscapeHtml(scenario.title)}">
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 = '<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 id="docScenarioTreeNodesList">${scenario.nodes.map((n, i) => forgeDocRenderScenarioNodeHtml(n, i, scenario.nodes, i === 0)).join('')}</div>
<button type="button" class="docBtnSecondary docQuizAddBtn docScenarioTreeAddNodeBtn">+ Ajouter un nœud</button>
<div class="docField">
<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 }));
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 = `
<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) {
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();
});
}
+63 -3
View File
@@ -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"] == []