Rewire hover as a flow trigger
Add "survol"/"fin_survol" trigger events (mouseenter/mouseleave, same anti-doublon pattern as bindClicks()) so hovering an element can run flow nodes, instead of the old static hover-text-only control removed from the properties panel. Also add "Contenu" as a settable "Modifier un élément" property so an action can display a defined text on another element on hover — the concrete use case that motivated this. Verified live with Playwright: a "Au survol" trigger on one element correctly updates another element's text via the action, and text stays put with no "Fin du survol" wired (explicit, no implicit revert, consistent with "Au clic").
This commit is contained in:
@@ -13,6 +13,15 @@
|
|||||||
TRIGGER_EVENTS = [
|
TRIGGER_EVENTS = [
|
||||||
("clic", "Au clic"),
|
("clic", "Au clic"),
|
||||||
("soumission", "À la soumission"),
|
("soumission", "À la soumission"),
|
||||||
|
# 3.1 (Confort) — interactions au survol, reconstruit comme déclencheur
|
||||||
|
# de flow (au lieu d'un réglage statique dans le panneau de propriétés,
|
||||||
|
# voir universal_controls.py) : "survol" et "fin_survol" sont deux
|
||||||
|
# déclencheurs distincts et explicites (comme "Au clic"), sans effet
|
||||||
|
# implicite — un créateur qui veut qu'un texte affiché au survol
|
||||||
|
# disparaisse ensuite doit poser l'action inverse sur "Fin du survol"
|
||||||
|
# lui-même, plutôt que de compter sur un retour automatique.
|
||||||
|
("survol", "Au survol"),
|
||||||
|
("fin_survol", "Fin du survol"),
|
||||||
# Pas de "trigger_element_id" pour celui-ci : il concerne l'ÉCRAN entier,
|
# Pas de "trigger_element_id" pour celui-ci : il concerne l'ÉCRAN entier,
|
||||||
# pas un élément précis (voir 1.2 dans claude/forge-engine-lacunes-boitemail.md
|
# pas un élément précis (voir 1.2 dans claude/forge-engine-lacunes-boitemail.md
|
||||||
# côté projet Forge — "affichage piloté par la donnée"). Se déclenche
|
# côté projet Forge — "affichage piloté par la donnée"). Se déclenche
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
ELEMENT_ACTION_PROPERTIES = [
|
ELEMENT_ACTION_PROPERTIES = [
|
||||||
("visibilite", "Visibilité"),
|
("visibilite", "Visibilité"),
|
||||||
|
# Voir TRIGGER_EVENTS ("survol"/"fin_survol") : c'est ce qui permet à un
|
||||||
|
# créateur de reconstruire l'ancien comportement "texte affiché au
|
||||||
|
# survol" (et bien d'autres) à la main dans la Logique de la scène —
|
||||||
|
# poser "Modifier un élément → Contenu" sur un déclencheur "Au survol"
|
||||||
|
# d'un AUTRE élément. La valeur est un texte littéral (pas encore de
|
||||||
|
# binding vers un champ d'objet ici — voir applyOpenRowBindings pour le
|
||||||
|
# mécanisme équivalent, propre à "Ouvrir la ligne cliquée").
|
||||||
|
("contenu", "Contenu — texte affiché"),
|
||||||
("bg_color", "Couleur de fond"),
|
("bg_color", "Couleur de fond"),
|
||||||
("border_color", "Couleur de bordure"),
|
("border_color", "Couleur de bordure"),
|
||||||
("width", "Largeur en %"),
|
("width", "Largeur en %"),
|
||||||
|
|||||||
@@ -2,4 +2,6 @@ TRIGGER_LABELS = {
|
|||||||
"clic": "Au clic",
|
"clic": "Au clic",
|
||||||
"soumission": "À la soumission",
|
"soumission": "À la soumission",
|
||||||
"affichage": "À l'affichage de l'écran",
|
"affichage": "À l'affichage de l'écran",
|
||||||
|
"survol": "Au survol",
|
||||||
|
"fin_survol": "Fin du survol",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -270,6 +270,26 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Déclencheurs "Au survol" / "Fin du survol" — même principe que
|
||||||
|
// bindClicks() (garde-fou anti-doublon compris), mais sur mouseenter/
|
||||||
|
// mouseleave plutôt que click, et les deux événements cherchés
|
||||||
|
// séparément puisque ce sont deux déclencheurs distincts et indépendants
|
||||||
|
// (voir le commentaire sur TRIGGER_EVENTS dans screens/flow/constants.py
|
||||||
|
// — pas de retour automatique à l'état d'avant survol, un créateur qui en
|
||||||
|
// veut un doit poser l'action inverse sur "Fin du survol" lui-même).
|
||||||
|
function bindHoverTriggers() {
|
||||||
|
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
|
||||||
|
if (elDiv.dataset.hoverTriggerBound) return;
|
||||||
|
const elementId = parseInt(elDiv.dataset.elementId, 10);
|
||||||
|
const enterTrigger = findTriggerNode(elementId, 'survol');
|
||||||
|
const leaveTrigger = findTriggerNode(elementId, 'fin_survol');
|
||||||
|
if (!enterTrigger && !leaveTrigger) return;
|
||||||
|
elDiv.dataset.hoverTriggerBound = '1';
|
||||||
|
if (enterTrigger) elDiv.addEventListener('mouseenter', () => runFlowFrom(enterTrigger.id));
|
||||||
|
if (leaveTrigger) elDiv.addEventListener('mouseleave', () => runFlowFrom(leaveTrigger.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Résout, sur l'écran couramment affiché, les placeholders "champ entre
|
// Résout, sur l'écran couramment affiché, les placeholders "champ entre
|
||||||
// doubles accolades" restés tels quels dans le HTML statique (hors
|
// doubles accolades" restés tels quels dans le HTML statique (hors
|
||||||
// Répéteur, donc jamais résolus au moment du rendu serveur) avec les
|
// Répéteur, donc jamais résolus au moment du rendu serveur) avec les
|
||||||
@@ -340,6 +360,7 @@
|
|||||||
applyOpenRowBindings();
|
applyOpenRowBindings();
|
||||||
bindClicks();
|
bindClicks();
|
||||||
bindHoverTexts();
|
bindHoverTexts();
|
||||||
|
bindHoverTriggers();
|
||||||
// Réévalue aussi les nœuds "À l'affichage de l'écran" de l'écran où
|
// Réévalue aussi les nœuds "À l'affichage de l'écran" de l'écran où
|
||||||
// l'on se trouve déjà : une donnée vient de changer (ex: un outil
|
// l'on se trouve déjà : une donnée vient de changer (ex: un outil
|
||||||
// qu'on vient de débloquer), donc l'affichage doit se mettre à jour
|
// qu'on vient de débloquer), donc l'affichage doit se mettre à jour
|
||||||
@@ -358,6 +379,16 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
function applyElementProperty(targetEl, property, value) {
|
function applyElementProperty(targetEl, property, value) {
|
||||||
|
if (property === 'contenu') {
|
||||||
|
// Voir ELEMENT_ACTION_PROPERTIES ("contenu") : texte littéral, pas de
|
||||||
|
// binding vers un champ d'objet ici (contrairement à
|
||||||
|
// applyOpenRowBindings, propre à "Ouvrir la ligne cliquée"). Comme
|
||||||
|
// pour les autres réglages ci-dessous, cible n'importe quel élément —
|
||||||
|
// pointer ceci vers un conteneur remplacerait tout son contenu
|
||||||
|
// imbriqué, à éviter.
|
||||||
|
targetEl.textContent = value == null ? '' : value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (property === 'surbrillance') {
|
if (property === 'surbrillance') {
|
||||||
// 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
|
// 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
|
||||||
// clignote (voir la classe .forgeHighlight et son animation dans le
|
// clignote (voir la classe .forgeHighlight et son animation dans le
|
||||||
@@ -603,6 +634,7 @@
|
|||||||
// directement sur l'écran.
|
// directement sur l'écran.
|
||||||
bindClicks();
|
bindClicks();
|
||||||
bindHoverTexts();
|
bindHoverTexts();
|
||||||
|
bindHoverTriggers();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -420,6 +420,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<input type="color" id="nf-action-element-value-color" style="display:none; margin-top:6px;" value="#5b8cff">
|
<input type="color" id="nf-action-element-value-color" style="display:none; margin-top:6px;" value="#5b8cff">
|
||||||
<input type="number" id="nf-action-element-value-number" style="display:none; margin-top:6px;" value="50" placeholder="Nouvelle valeur en %">
|
<input type="number" id="nf-action-element-value-number" style="display:none; margin-top:6px;" value="50" placeholder="Nouvelle valeur en %">
|
||||||
|
<input type="text" id="nf-action-element-value-text" style="display:none; margin-top:6px;" placeholder="Nouveau texte affiché">
|
||||||
|
|
||||||
<label id="nf-action-element-toggle-row" style="display:flex; align-items:center; gap:6px; margin-top:10px; font-size:12px; color:var(--text-dim);">
|
<label id="nf-action-element-toggle-row" style="display:flex; align-items:center; gap:6px; margin-top:10px; font-size:12px; color:var(--text-dim);">
|
||||||
<input type="checkbox" id="nf-action-element-toggle" onchange="toggleFlowElementToggleMode()" style="width:auto; margin:0;">
|
<input type="checkbox" id="nf-action-element-toggle" onchange="toggleFlowElementToggleMode()" style="width:auto; margin:0;">
|
||||||
@@ -1325,8 +1326,9 @@ function toggleFlowElementPropertyValue() {
|
|||||||
var desactiveSel = document.getElementById('nf-action-element-value-desactive');
|
var desactiveSel = document.getElementById('nf-action-element-value-desactive');
|
||||||
var colorInp = document.getElementById('nf-action-element-value-color');
|
var colorInp = document.getElementById('nf-action-element-value-color');
|
||||||
var numInp = document.getElementById('nf-action-element-value-number');
|
var numInp = document.getElementById('nf-action-element-value-number');
|
||||||
|
var textInp = document.getElementById('nf-action-element-value-text');
|
||||||
var toggleRow = document.getElementById('nf-action-element-toggle-row');
|
var toggleRow = document.getElementById('nf-action-element-toggle-row');
|
||||||
[visSel, surbrillanceSel, desactiveSel, colorInp, numInp].forEach(function(el){ el.style.display = 'none'; });
|
[visSel, surbrillanceSel, desactiveSel, colorInp, numInp, textInp].forEach(function(el){ el.style.display = 'none'; });
|
||||||
if (propSel.value === 'visibilite') {
|
if (propSel.value === 'visibilite') {
|
||||||
visSel.style.display = 'block';
|
visSel.style.display = 'block';
|
||||||
toggleRow.style.display = 'none';
|
toggleRow.style.display = 'none';
|
||||||
@@ -1339,6 +1341,10 @@ function toggleFlowElementPropertyValue() {
|
|||||||
desactiveSel.style.display = 'block';
|
desactiveSel.style.display = 'block';
|
||||||
toggleRow.style.display = 'none';
|
toggleRow.style.display = 'none';
|
||||||
document.getElementById('nf-action-element-toggle').checked = false;
|
document.getElementById('nf-action-element-toggle').checked = false;
|
||||||
|
} else if (propSel.value === 'contenu') {
|
||||||
|
textInp.style.display = 'block';
|
||||||
|
toggleRow.style.display = 'none';
|
||||||
|
document.getElementById('nf-action-element-toggle').checked = false;
|
||||||
} else if (propSel.value === 'bg_color' || propSel.value === 'border_color') {
|
} else if (propSel.value === 'bg_color' || propSel.value === 'border_color') {
|
||||||
colorInp.style.display = 'block';
|
colorInp.style.display = 'block';
|
||||||
toggleRow.style.display = 'flex';
|
toggleRow.style.display = 'flex';
|
||||||
@@ -1413,6 +1419,8 @@ function submitNodeForm() {
|
|||||||
payload.element_value = document.getElementById('nf-action-element-value-surbrillance').value;
|
payload.element_value = document.getElementById('nf-action-element-value-surbrillance').value;
|
||||||
} else if (prop === 'desactive') {
|
} else if (prop === 'desactive') {
|
||||||
payload.element_value = document.getElementById('nf-action-element-value-desactive').value;
|
payload.element_value = document.getElementById('nf-action-element-value-desactive').value;
|
||||||
|
} else if (prop === 'contenu') {
|
||||||
|
payload.element_value = document.getElementById('nf-action-element-value-text').value;
|
||||||
} else {
|
} else {
|
||||||
var v1 = isColorProp
|
var v1 = isColorProp
|
||||||
? document.getElementById('nf-action-element-value-color').value
|
? document.getElementById('nf-action-element-value-color').value
|
||||||
|
|||||||
+61
-3
@@ -22,9 +22,10 @@ def test_hover_text_control_removed_from_properties_panel(client, game):
|
|||||||
"""Le réglage "Survol" a été retiré du panneau de propriétés (voir
|
"""Le réglage "Survol" a été retiré du panneau de propriétés (voir
|
||||||
universal_controls.py) : survoler un élément est conceptuellement un
|
universal_controls.py) : survoler un élément est conceptuellement un
|
||||||
déclencheur de la Logique de la scène, pas une propriété statique —
|
déclencheur de la Logique de la scène, pas une propriété statique —
|
||||||
il sera réintroduit là-bas. Poster ctrl_survol_texte ne doit donc plus
|
reconstruit là-bas avec les déclencheurs "survol"/"fin_survol" (voir
|
||||||
avoir d'effet (le mécanisme data-hover-text/bindHoverTexts sous-jacent
|
plus bas). Poster ctrl_survol_texte ne doit donc plus avoir d'effet (le
|
||||||
reste en place pour un usage futur, voir les tests ci-dessous)."""
|
mécanisme data-hover-text/bindHoverTexts sous-jacent reste en place, il
|
||||||
|
n'est simplement plus réglable depuis ce panneau)."""
|
||||||
screen_id = _create_screen(client, game)
|
screen_id = _create_screen(client, game)
|
||||||
el_id = _add_element(client, game, screen_id, "titre")
|
el_id = _add_element(client, game, screen_id, "titre")
|
||||||
client.post(f"/game/{game}/elements/{el_id}/save", data={
|
client.post(f"/game/{game}/elements/{el_id}/save", data={
|
||||||
@@ -54,6 +55,63 @@ def test_play_page_exposes_hover_binding_runtime(client, game):
|
|||||||
assert "bindHoverTexts" in html
|
assert "bindHoverTexts" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_survol_trigger_node_persists(client, game):
|
||||||
|
screen_id = _create_screen(client, game)
|
||||||
|
el_id = _add_element(client, game, screen_id, "titre")
|
||||||
|
resp = client.post(
|
||||||
|
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||||
|
data=json.dumps({"node_type": "trigger", "trigger_element_id": el_id, "trigger_event": "survol"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
node = resp.get_json()
|
||||||
|
assert node["trigger_event"] == "survol"
|
||||||
|
assert node["trigger_element_id"] == el_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_play_page_exposes_hover_trigger_runtime(client, game):
|
||||||
|
"""bindHoverTriggers() (mouseenter/mouseleave -> runFlowFrom) doit être
|
||||||
|
exposé et appelé, exactement comme bindClicks() pour "Au clic"."""
|
||||||
|
resp = client.get(f"/game/{game}/play")
|
||||||
|
html = resp.data.decode()
|
||||||
|
assert "bindHoverTriggers" in html
|
||||||
|
assert "'survol'" in html
|
||||||
|
assert "'fin_survol'" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_modifier_element_contenu_action_persists(client, game):
|
||||||
|
""""Modifier un élément → Contenu" : c'est ce qui permet de reconstruire
|
||||||
|
l'ancien "texte affiché au survol" (et bien d'autres usages) à la main
|
||||||
|
dans la Logique de la scène, en le combinant avec un déclencheur
|
||||||
|
"Au survol" posé sur un AUTRE élément."""
|
||||||
|
screen_id = _create_screen(client, game)
|
||||||
|
source_id = _add_element(client, game, screen_id, "titre")
|
||||||
|
target_id = _add_element(client, game, screen_id, "texte")
|
||||||
|
resp = client.post(
|
||||||
|
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||||
|
data=json.dumps({
|
||||||
|
"node_type": "action", "action_type": "modifier_element",
|
||||||
|
"target_element_id": target_id, "element_property": "contenu", "element_value": "Survol actif !",
|
||||||
|
}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
node = resp.get_json()
|
||||||
|
assert node["element_property"] == "contenu"
|
||||||
|
assert node["element_value"] == "Survol actif !"
|
||||||
|
assert node["target_element_id"] == target_id
|
||||||
|
# source_id n'est utilisé que pour documenter le scénario (le
|
||||||
|
# déclencheur "Au survol" se poserait dessus) — non exercé ici, déjà
|
||||||
|
# couvert par test_survol_trigger_node_persists.
|
||||||
|
assert source_id != target_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_play_page_exposes_contenu_property_runtime(client, game):
|
||||||
|
resp = client.get(f"/game/{game}/play")
|
||||||
|
html = resp.data.decode()
|
||||||
|
assert "'contenu'" in html
|
||||||
|
|
||||||
|
|
||||||
# ---------- 3.2 — Séquences temporisées ----------
|
# ---------- 3.2 — Séquences temporisées ----------
|
||||||
|
|
||||||
def test_attendre_action_node_persists_delay(client, game):
|
def test_attendre_action_node_persists_delay(client, game):
|
||||||
|
|||||||
Reference in New Issue
Block a user