Bug rapporté : changer une variable globale utilisée comme valeur de comparaison d'une "Donnée liée" (Texte/Titre, dans une boîte de dialogue posée comme élément de jeu réutilisable) recalculait bien, côté SERVEUR, la bonne ligne à afficher — mais le contenu affiché en jeu restait figé sur son ancienne ligne tant que la page n'était pas complètement rechargée. Cause : templates/play.html ne régénère, après une action "Modifier une variable"/"Modifier une donnée", QUE les éléments dont le rendered_html porte un marqueur connu (voir refreshRuntimeData()/hasMarker() — "repeaterItem", "jaugeBar", "visibilityGated"). Un Texte/Titre "Donnée liée" n'en portait AUCUN : jamais identifié comme "dépendant de la donnée", donc jamais régénéré, même si le nouveau HTML était déjà prêt côté serveur à chaque rendu. Correctif : nouveau marqueur "dataBound" (render_element_html.py), posé dès que _data_definition_id est réglé, reconnu par hasMarker(). Un exemplaire d'élément de jeu (ex. la boîte de dialogue posée sur une scène) porte ce marqueur EN PROFONDEUR dans son propre rendered_html dès qu'un de ses descendants internes en a un — il se retrouve donc bien régénéré dans son ensemble, sans changement supplémentaire nécessaire. Deux nouveaux tests, confirmés en échec sur l'ancien code (même scénario que le rapport : reproduit avec objet "dialog" + variable "dialog_order" + élément de jeu réutilisable) puis au vert avec le correctif. 155 tests au vert au total. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
218 lines
12 KiB
Python
218 lines
12 KiB
Python
import html as html_lib
|
|
|
|
from ..widgets.widget_meta import widget_meta
|
|
from .visible_attrs import _visible_attrs
|
|
from .style_string import _style_string
|
|
from .attr_string import _attr_string
|
|
from .apply_ctx import _apply_ctx
|
|
from .render_children import _render_children
|
|
from .render_table import _render_table
|
|
from .render_select import _render_select
|
|
from .render_fieldset import _render_fieldset
|
|
from .render_checkbox_or_radio import _render_checkbox_or_radio
|
|
from .render_repeater import _render_repeater
|
|
from .render_jauge import _render_jauge
|
|
from .render_overlay import _render_overlay
|
|
from .render_onglets import _render_onglets
|
|
from .render_icone import _render_icone
|
|
from .resolve_bound_row import _resolve_bound_row_ctx
|
|
from .visibility_condition import is_element_visible
|
|
from ..element_types.load_template_tree import _load_template_tree
|
|
from ..element_types.is_overlay_only import _is_overlay_only_element_type
|
|
|
|
|
|
def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
|
"""Construit le HTML réel (balise + attributs + style + contenu, y
|
|
compris les éléments physiquement posés à l'intérieur pour un
|
|
conteneur/répéteur/groupe de champs) — utilisé par l'éditeur (canevas)
|
|
et le mode jouable. N'écrit jamais rien sur disque : uniquement du HTML
|
|
généré à la volée à partir de ce qui est stocké en base. `slug` est
|
|
nécessaire pour le Répéteur de données (il doit lire les données du jeu
|
|
au moment de l'affichage). `ctx` porte les valeurs {{champ}} de la ligne
|
|
en cours quand cet élément est descendant d'un répéteur. `parent_flex_direction`
|
|
est la disposition interne (voir _parent_flex_direction) du conteneur qui
|
|
pose physiquement cet élément, s'il y en a un — elle influence la largeur
|
|
par défaut de cet élément (voir _style_string)."""
|
|
meta = widget_meta(el.get("widget"))
|
|
attrs_raw = el.get("attributes") or {}
|
|
# La condition de visibilité (voir VISIBILITY_CONDITION_CONTROLS) n'est
|
|
# appliquée qu'en mode JOUABLE (ctx["_forge_play_mode"], posé par
|
|
# full_game_payload.py via list_elements(..., enforce_visibility=True))
|
|
# — jamais dans l'éditeur, où l'élément doit rester visible/sélectionnable
|
|
# même si la condition serait actuellement fausse, sans quoi il
|
|
# deviendrait impossible de le retrouver pour le régler. Un commentaire
|
|
# marqueur ("visibilityGated") reste posé sur le HTML même quand la
|
|
# condition est vraie, pour que refreshRuntimeData() (play.html) sache
|
|
# qu'il faut réévaluer cet élément après toute action "Modifier une
|
|
# donnée"/"Modifier une variable" — pas seulement Jauge/Répéteur, qui ont
|
|
# déjà chacun leur propre marqueur ("jaugeBar"/"repeaterItem") couvrant
|
|
# ce cas d'office.
|
|
has_visibility_condition = attrs_raw.get("_visibilite_cond_mode") in ("variable", "objet")
|
|
play_mode = bool((ctx or {}).get("_forge_play_mode"))
|
|
if play_mode and has_visibility_condition and not is_element_visible(slug, attrs_raw, ctx):
|
|
return f'<div class="visibilityGated" data-element-id="{el["id"]}" style="display:none;"></div>'
|
|
|
|
# "Donnée liée" (DATA_BINDING_CONTROLS, Texte/Titre) : son contenu
|
|
# ({{champ}}) dépend de la ligne trouvée par ses conditions au moment du
|
|
# rendu — potentiellement via une "Variable globale" en valeur de
|
|
# comparaison (voir _resolve_filter_value). Comme "visibilityGated"
|
|
# juste au-dessus, un marqueur ("dataBound") reste posé sur le HTML pour
|
|
# que refreshRuntimeData() (play.html) sache qu'il faut réévaluer cet
|
|
# élément après toute action "Modifier une donnée"/"Modifier une
|
|
# variable" — sans lui, changer la variable choisie changeait bien QUELLE
|
|
# ligne aurait dû s'afficher (recalculé correctement côté serveur à
|
|
# chaque rendu), mais le client ne redemandait jamais le nouveau HTML de
|
|
# CET élément précis : le texte affiché restait figé sur son ancienne
|
|
# ligne jusqu'au prochain rechargement complet de la page.
|
|
has_data_binding = bool(attrs_raw.get("_data_definition_id"))
|
|
|
|
def _mark(html_str):
|
|
markers = ""
|
|
if has_visibility_condition:
|
|
markers += "<!--visibilityGated-->"
|
|
if has_data_binding:
|
|
markers += "<!--dataBound-->"
|
|
return html_str + markers
|
|
|
|
bound_ctx = _resolve_bound_row_ctx(slug, attrs_raw)
|
|
if bound_ctx:
|
|
ctx = {**(ctx or {}), **bound_ctx}
|
|
# Chaque branche "special_render" passe elle aussi par _mark() : sans
|
|
# ça, un widget de ce genre (Icône, Tableau, Superposition...) actuellement
|
|
# VISIBLE sous condition ne portait aucun marqueur, et refreshRuntimeData()
|
|
# (play.html) ne savait donc jamais qu'il fallait le réévaluer après une
|
|
# action "Modifier une donnée/variable" — il restait figé dans son état
|
|
# de l'affichage précédent (ex: une icône "enveloppe fermée" restée
|
|
# visible après l'ouverture du mail, en même temps que "enveloppe
|
|
# ouverte" qui apparaît, elle, correctement puisqu'un élément CACHÉ
|
|
# porte toujours son marqueur via le placeholder ci-dessus).
|
|
special = meta.get("special_render")
|
|
if special == "table":
|
|
return _mark(_render_table(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "select_options":
|
|
return _mark(_render_select(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "fieldset_legend":
|
|
return _mark(_render_fieldset(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "checkbox_label":
|
|
return _mark(_render_checkbox_or_radio(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "repeater":
|
|
return _mark(_render_repeater(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "jauge":
|
|
return _mark(_render_jauge(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "overlay":
|
|
return _mark(_render_overlay(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "onglets":
|
|
return _mark(_render_onglets(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
if special == "icone":
|
|
return _mark(_render_icone(el, meta, slug, children_map, ctx, parent_flex_direction))
|
|
|
|
tag = meta.get("tag")
|
|
if tag is None: # widget à balise dynamique (ex: "titre" -> h1/h2/h3)
|
|
niveau = (el.get("attributes") or {}).get("_niveau", "2")
|
|
tag = f"h{niveau}"
|
|
|
|
attrs = _visible_attrs(el, meta, ctx)
|
|
style = _style_string(el, parent_flex_direction=parent_flex_direction, widget_key=el.get("widget"))
|
|
|
|
# 3.1 (Confort) — interactions au survol : un texte de remplacement
|
|
# optionnel, affiché à la place du contenu normal quand le joueur
|
|
# survole cet élément à la souris (voir bindHoverTexts() dans
|
|
# templates/play.html). Absent par défaut => aucun changement, donc
|
|
# aucune régression sur les éléments déjà créés.
|
|
hover_text = (el.get("attributes") or {}).get("_survol_texte")
|
|
if hover_text:
|
|
attrs["data-hover-text"] = _apply_ctx(hover_text, ctx)
|
|
|
|
if meta.get("self_closing"):
|
|
return _mark(f"<{tag} {_attr_string(attrs, style)}>")
|
|
|
|
content = _apply_ctx(el.get("content") or "", ctx)
|
|
child_html, is_overlay_template = (
|
|
_render_element_type_children(el, slug, ctx) if el.get("element_type_id") else (None, False)
|
|
)
|
|
if child_html is None:
|
|
child_html = _render_children(el, slug, children_map, ctx)
|
|
elif is_overlay_template:
|
|
# Un exemplaire d'élément de jeu est posé par défaut avec le widget
|
|
# générique "conteneur" (add_element.py, "default_widget") — utile
|
|
# pour la plupart des modèles, mais QUAND le modèle entier n'est
|
|
# qu'une "Superposition / boîte de dialogue", la classe Bulma "box"
|
|
# de ce widget (widgets/registry.py "conteneur") lui donnait un
|
|
# fond opaque TOUJOURS VISIBLE à l'endroit où l'exemplaire a été
|
|
# déposé, alors que la superposition à l'intérieur gère déjà
|
|
# entièrement sa propre apparence et son propre masquage
|
|
# (position:fixed plein écran, démarre masquée) — vécu comme "un
|
|
# conteneur vide apparaît sur la scène, pas la boîte de dialogue"
|
|
# (en réalité la boîte de dialogue existe bien, juste masquée comme
|
|
# prévu ; c'est le conteneur AUTOUR qui n'aurait jamais dû avoir sa
|
|
# propre apparence visuelle).
|
|
#
|
|
# On ne court-circuite PLUS entièrement cette enveloppe (un essai
|
|
# précédent le faisait, en renvoyant `child_html` directement) :
|
|
# cette balise porte le SEUL id/data-element-id de CET EXEMPLAIRE
|
|
# précis (id=el["id"], distinct de l'id interne du modèle,
|
|
# ex. l'overlay du modèle "dialog" garde son propre id 72 alors que
|
|
# CET exemplaire est, par ex., 77) — une action "Modifier un
|
|
# élément" posée sur LA SCÈNE qui l'accueille (pas sur le modèle
|
|
# lui-même) cible cet id d'exemplaire, jamais celui, interne et
|
|
# partagé par tous les exemplaires, du modèle. La court-circuiter
|
|
# entièrement faisait disparaître cet id du DOM, rendant
|
|
# IMPOSSIBLE tout contrôle de la boîte de dialogue depuis la
|
|
# logique de la scène qui l'accueille ("j'ai aucun contrôle sur la
|
|
# visibilité de la modale depuis la scène"). On garde donc la
|
|
# balise (avec son id), mais SANS la classe "box" du widget
|
|
# générique — juste une balise transparente, invisible tant
|
|
# qu'elle ne porte ni fond ni bordure."""
|
|
attrs.pop("class", None)
|
|
return _mark(f"<div {_attr_string(attrs, style)}>{child_html}</div>")
|
|
|
|
if tag in ("ul", "ol"):
|
|
items = [line.strip() for line in content.split("\n") if line.strip()]
|
|
inner = "".join(f"<li>{html_lib.escape(item)}</li>" for item in items) + child_html
|
|
return _mark(f"<{tag} {_attr_string(attrs, style)}>{inner}</{tag}>")
|
|
|
|
safe_content = html_lib.escape(content).replace("\n", "<br>") if content else ""
|
|
return _mark(f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}</{tag}>")
|
|
|
|
|
|
def _render_element_type_children(el, slug, ctx):
|
|
"""Contenu d'un exemplaire d'élément de jeu posé sur un écran (voir
|
|
add_element.py, colonne element_type_id) : rechargé EN DIRECT depuis
|
|
l'écran-modèle à chaque affichage, jamais depuis d'éventuels enfants
|
|
copiés en base — exactement le même principe qu'un modèle de ligne de
|
|
Répéteur (voir _render_repeater/_load_template_tree). Modifier
|
|
l'élément de jeu dans son propre éditeur met donc à jour tous ses
|
|
exemplaires déjà posés, sur n'importe quel écran, sans avoir à les
|
|
retoucher un par un. Renvoie (None, False) si l'élément de jeu n'a
|
|
plus de modèle valide, pour que l'appelant retombe sur le rendu
|
|
générique (d'éventuels enfants en base issus d'une version antérieure
|
|
de ce mécanisme) plutôt que d'afficher un exemplaire silencieusement
|
|
vide. Le second élément renvoyé indique si le modèle N'EST QUE une
|
|
superposition (voir l'appelant : dans ce cas précis, l'enveloppe
|
|
générique "conteneur" de l'exemplaire ne doit pas avoir sa propre
|
|
apparence visuelle)."""
|
|
template_tree = _load_template_tree(slug, el["element_type_id"])
|
|
if not template_tree:
|
|
return None, False
|
|
is_overlay_only = _is_overlay_only_element_type(slug, el["element_type_id"])
|
|
if is_overlay_only:
|
|
# Le masquage de CET EXEMPLAIRE est entièrement délégué à
|
|
# l'enveloppe (voir l'appelant : add_element.py la fait démarrer
|
|
# masquée par défaut, exactement comme la superposition elle-même)
|
|
# — la superposition INTERNE au modèle ne doit donc plus appliquer
|
|
# SON PROPRE réglage "Visibilité" une fois posée comme exemplaire
|
|
# ailleurs (sinon double masquage : rendre l'enveloppe visible
|
|
# depuis la scène qui l'accueille resterait sans aucun effet
|
|
# visible tant que la superposition interne, elle, reste "Masqué"
|
|
# dans le modèle). On retire donc "display" de sa copie de style
|
|
# avant rendu (copie éphémère, jamais réenregistrée) — seul le
|
|
# display:flex de base (render_overlay.py) s'applique alors.
|
|
overlay_root = dict(template_tree["top"][0])
|
|
overlay_root["style"] = {k: v for k, v in overlay_root["style"].items() if k != "display"}
|
|
template_tree["top"][0] = overlay_root
|
|
html = "".join(
|
|
render_element_html(c, slug, template_tree["children_map"], ctx)
|
|
for c in template_tree["top"]
|
|
)
|
|
return html, is_overlay_only
|