first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Génération du HTML réel des éléments posés sur un écran."""
|
||||
@@ -0,0 +1,9 @@
|
||||
def _apply_ctx(text, ctx):
|
||||
"""À l'intérieur d'un Répéteur de données, {{champ}} est remplacé par
|
||||
la valeur de ce champ pour la ligne en cours — appliqué au contenu ET
|
||||
aux attributs de l'élément et de tous ses descendants."""
|
||||
if not ctx or not text:
|
||||
return text
|
||||
for k, v in ctx.items():
|
||||
text = text.replace("{{" + k + "}}", v)
|
||||
return text
|
||||
@@ -0,0 +1,10 @@
|
||||
import html as html_lib
|
||||
|
||||
|
||||
def _attr_string(attrs, extra_style):
|
||||
parts = [f'style="{html_lib.escape(extra_style)}"']
|
||||
for k, v in attrs.items():
|
||||
if v in (None, "", False):
|
||||
continue
|
||||
parts.append(html_lib.escape(str(k)) if v is True else f'{html_lib.escape(str(k))}="{html_lib.escape(str(v))}"')
|
||||
return " ".join(parts)
|
||||
@@ -0,0 +1,2 @@
|
||||
def _auto_id(el):
|
||||
return f"elt-{el['id']}"
|
||||
@@ -0,0 +1,94 @@
|
||||
import re
|
||||
|
||||
import db
|
||||
|
||||
# {{NomDeLObjet.nom_du_champ}} : référence à un AUTRE objet de données, pour
|
||||
# comparer le champ filtré à une valeur qui change en cours de partie (ex:
|
||||
# le niveau atteint) plutôt qu'à une valeur fixe tapée en dur — même
|
||||
# convention de syntaxe que les {{champ}} déjà utilisés ailleurs (contenu
|
||||
# d'un Répéteur, voir _apply_ctx / applyOpenRowBindings côté JS).
|
||||
_REF_PATTERN = re.compile(r"^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$")
|
||||
|
||||
|
||||
def _resolve_filter_value(slug, raw_value):
|
||||
"""Si raw_value est une référence "{{Objet.champ}}", va lire la valeur
|
||||
ACTUELLE de ce champ sur la ligne la plus récente de cet objet (par
|
||||
convention, un objet utilisé comme "état de partie" — niveau courant,
|
||||
outils débloqués... — ne garde qu'une seule ligne, mise à jour en place
|
||||
par des actions "Modifier une donnée" plutôt que d'en créer une
|
||||
nouvelle à chaque fois). Sinon, renvoie raw_value tel quel (valeur
|
||||
fixe)."""
|
||||
if not raw_value:
|
||||
return raw_value
|
||||
m = _REF_PATTERN.match(raw_value.strip())
|
||||
if not m:
|
||||
return raw_value
|
||||
object_name, field_name = m.group(1).strip(), m.group(2).strip()
|
||||
for d in db.list_definitions(slug):
|
||||
if d["name"] == object_name:
|
||||
full = db.get_definition(slug, d["id"])
|
||||
rows = db.list_rows(slug, full)
|
||||
if rows:
|
||||
return rows[0].get(db.slugify(field_name).replace("-", "_"))
|
||||
return None
|
||||
return None # objet de référence introuvable — filtre ne matchera rien plutôt que planter
|
||||
|
||||
|
||||
def _compare(actual, operator, expected, field_type):
|
||||
"""Équivalent Python de compareValues() dans templates/play.html — les
|
||||
deux doivent rester alignées si on ajoute un opérateur."""
|
||||
if field_type == "booleen":
|
||||
a = 1 if actual else 0
|
||||
e = 1 if str(expected).strip().lower() in ("1", "true", "vrai") else 0
|
||||
return a != e if operator == "different" else a == e
|
||||
try:
|
||||
an, en = float(actual), float(expected)
|
||||
numeric = True
|
||||
except (TypeError, ValueError):
|
||||
numeric = False
|
||||
if numeric:
|
||||
if operator == "egal":
|
||||
return an == en
|
||||
if operator == "different":
|
||||
return an != en
|
||||
if operator == "superieur":
|
||||
return an > en
|
||||
if operator == "inferieur":
|
||||
return an < en
|
||||
if operator == "superieur_egal":
|
||||
return an >= en
|
||||
if operator == "inferieur_egal":
|
||||
return an <= en
|
||||
return False
|
||||
as_, es_ = ("" if actual is None else str(actual)), ("" if expected is None else str(expected))
|
||||
if operator == "egal":
|
||||
return as_ == es_
|
||||
if operator == "different":
|
||||
return as_ != es_
|
||||
if operator == "superieur":
|
||||
return as_ > es_
|
||||
if operator == "inferieur":
|
||||
return as_ < es_
|
||||
if operator == "superieur_egal":
|
||||
return as_ >= es_
|
||||
if operator == "inferieur_egal":
|
||||
return as_ <= es_
|
||||
return False
|
||||
|
||||
|
||||
def filter_repeater_rows(slug, definition, rows, attrs):
|
||||
"""Ne garde, parmi les lignes d'un Répéteur, que celles qui correspondent
|
||||
au filtre optionnel réglé dans ses propriétés (_filtre_champ /
|
||||
_filtre_operateur / _filtre_valeur) — voir 1.2 dans la doc de cadrage
|
||||
Forge Engine ("affichage piloté par la donnée sur un écran unique") :
|
||||
c'est ce qui permet à un seul écran de n'afficher, par exemple, que les
|
||||
mails du niveau en cours plutôt que la boîte de réception entière."""
|
||||
field_name = (attrs.get("_filtre_champ") or "").strip()
|
||||
operator = attrs.get("_filtre_operateur") or "egal"
|
||||
if not field_name:
|
||||
return rows
|
||||
raw_value = attrs.get("_filtre_valeur") or ""
|
||||
expected = _resolve_filter_value(slug, raw_value)
|
||||
field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte")
|
||||
col = db.slugify(field_name).replace("-", "_")
|
||||
return [r for r in rows if _compare(r.get(col), operator, expected, field_type)]
|
||||
@@ -0,0 +1,9 @@
|
||||
def _parent_flex_direction(parent_el):
|
||||
"""Sens de la disposition interne d'un conteneur/répéteur/groupe de
|
||||
champs (voir le réglage "Disposition interne"), ou None s'il est en
|
||||
empilement libre (pas de display:flex) — utilisé pour adapter la taille
|
||||
par défaut de ses enfants (voir _style_string)."""
|
||||
style = (parent_el or {}).get("style") or {}
|
||||
if style.get("display") != "flex":
|
||||
return None
|
||||
return style.get("flex-direction") or "row"
|
||||
@@ -0,0 +1,15 @@
|
||||
import html as html_lib
|
||||
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .apply_ctx import _apply_ctx
|
||||
from .style_string import _style_string
|
||||
from .attr_string import _attr_string
|
||||
|
||||
|
||||
def _render_checkbox_or_radio(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
input_type = "checkbox" if el.get("widget") == "case_a_cocher" else "radio"
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
attrs["type"] = input_type
|
||||
style = _style_string(el, " display:flex; align-items:center; gap:8px;", parent_flex_direction)
|
||||
content = _apply_ctx(el.get("content") or "", ctx)
|
||||
return f'<label {_attr_string({}, style)}><input {_attr_string(attrs, "")}> {html_lib.escape(content)}</label>'
|
||||
@@ -0,0 +1,13 @@
|
||||
from .parent_flex_direction import _parent_flex_direction
|
||||
|
||||
|
||||
def _render_children(el, slug, children_map, ctx):
|
||||
"""Rendu récursif des éléments physiquement posés à l'intérieur d'un
|
||||
conteneur/répéteur/groupe de champs — chacun avec sa propre disposition,
|
||||
et le même contexte de ligne (ctx) que son parent s'il est dans un
|
||||
répéteur de données."""
|
||||
from .render_element_html import render_element_html
|
||||
|
||||
kids = (children_map or {}).get(el["id"], [])
|
||||
flex_dir = _parent_flex_direction(el)
|
||||
return "".join(render_element_html(c, slug, children_map, ctx, parent_flex_direction=flex_dir) for c in kids)
|
||||
@@ -0,0 +1,76 @@
|
||||
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
|
||||
|
||||
|
||||
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"))
|
||||
special = meta.get("special_render")
|
||||
if special == "table":
|
||||
return _render_table(el, meta, slug, children_map, ctx, parent_flex_direction)
|
||||
if special == "select_options":
|
||||
return _render_select(el, meta, slug, children_map, ctx, parent_flex_direction)
|
||||
if special == "fieldset_legend":
|
||||
return _render_fieldset(el, meta, slug, children_map, ctx, parent_flex_direction)
|
||||
if special == "checkbox_label":
|
||||
return _render_checkbox_or_radio(el, meta, slug, children_map, ctx, parent_flex_direction)
|
||||
if special == "repeater":
|
||||
return _render_repeater(el, meta, slug, children_map, ctx, parent_flex_direction)
|
||||
if special == "jauge":
|
||||
return _render_jauge(el, meta, slug, children_map, ctx, parent_flex_direction)
|
||||
if special == "overlay":
|
||||
return _render_overlay(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 f"<{tag} {_attr_string(attrs, style)}>"
|
||||
|
||||
content = _apply_ctx(el.get("content") or "", ctx)
|
||||
child_html = _render_children(el, slug, children_map, ctx)
|
||||
|
||||
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 f"<{tag} {_attr_string(attrs, style)}>{inner}</{tag}>"
|
||||
|
||||
safe_content = html_lib.escape(content).replace("\n", "<br>") if content else ""
|
||||
return f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}</{tag}>"
|
||||
@@ -0,0 +1,15 @@
|
||||
import html as html_lib
|
||||
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .apply_ctx import _apply_ctx
|
||||
from .style_string import _style_string
|
||||
from .attr_string import _attr_string
|
||||
from .render_children import _render_children
|
||||
|
||||
|
||||
def _render_fieldset(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
style = _style_string(el, parent_flex_direction=parent_flex_direction, widget_key=el.get("widget"))
|
||||
content = _apply_ctx(el.get("content") or "", ctx) or "Groupe de champs"
|
||||
child_html = _render_children(el, slug, children_map, ctx)
|
||||
return f'<fieldset {_attr_string(attrs, style)}><legend>{html_lib.escape(content)}</legend>{child_html}</fieldset>'
|
||||
@@ -0,0 +1,97 @@
|
||||
import html as html_lib
|
||||
|
||||
import db
|
||||
|
||||
from .style_string import _style_string
|
||||
from .auto_id import _auto_id
|
||||
|
||||
|
||||
def _hex_to_rgb(hex_color):
|
||||
hex_color = (hex_color or "#5b8cff").lstrip("#")
|
||||
if len(hex_color) != 6:
|
||||
hex_color = "5b8cff"
|
||||
return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def _interpolate_color(low_hex, high_hex, ratio):
|
||||
"""Dégradé linéaire simple entre deux couleurs (rouge en bas, vert en
|
||||
haut par défaut, comme demandé dans le document d'interface de Boîte
|
||||
Mail) — ratio est déjà borné entre 0 et 1 par l'appelant."""
|
||||
lo, hi = _hex_to_rgb(low_hex), _hex_to_rgb(high_hex)
|
||||
mixed = tuple(round(lo[i] + (hi[i] - lo[i]) * ratio) for i in range(3))
|
||||
return "#{:02x}{:02x}{:02x}".format(*mixed)
|
||||
|
||||
|
||||
def _read_latest_field_value(slug, definition_id, field_name):
|
||||
"""Lit la valeur du champ demandé sur la ligne la PLUS RÉCENTE de
|
||||
l'objet visé — même convention que le filtre de Répéteur (1.2) : un
|
||||
objet utilisé comme jauge de partie (Réputation, Trésorerie...) ne
|
||||
garde qu'une seule ligne, mise à jour en place."""
|
||||
if not definition_id or not slug:
|
||||
return None
|
||||
definition = db.get_definition(slug, int(definition_id))
|
||||
if not definition:
|
||||
return None
|
||||
rows = db.list_rows(slug, definition)
|
||||
if not rows:
|
||||
return None
|
||||
col = db.slugify(field_name).replace("-", "_")
|
||||
return rows[0].get(col)
|
||||
|
||||
|
||||
def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
"""Jauge/barre dont le remplissage (largeur en %) et la couleur (dégradé
|
||||
bas -> haut) se recalculent automatiquement à partir d'un champ
|
||||
numérique d'un objet de données — voir 2.1 dans la doc de cadrage Forge
|
||||
Engine ("binding live d'une donnée vers un style"). Recalculée à chaque
|
||||
régénération du HTML du jeu (chargement de /play, et après toute action
|
||||
"Modifier une donnée"), donc toujours à jour sans action supplémentaire
|
||||
à poser dans le graphe de logique."""
|
||||
attrs = el.get("attributes") or {}
|
||||
style = _style_string(el, " overflow:hidden; position:relative; background:#1c2130; border-radius:6px;", parent_flex_direction)
|
||||
definition_id = attrs.get("_definition_id")
|
||||
field_name = (attrs.get("_champ") or "").strip()
|
||||
try:
|
||||
min_v = float(attrs.get("_min", "0") or 0)
|
||||
except (TypeError, ValueError):
|
||||
min_v = 0.0
|
||||
try:
|
||||
max_v = float(attrs.get("_max", "100") or 100)
|
||||
except (TypeError, ValueError):
|
||||
max_v = 100.0
|
||||
couleur_basse = attrs.get("_couleur_basse") or "#e05252"
|
||||
couleur_haute = attrs.get("_couleur_haute") or "#4caf50"
|
||||
afficher_valeur = attrs.get("_afficher_valeur") == "1"
|
||||
|
||||
raw_value = _read_latest_field_value(slug, definition_id, field_name) if field_name else None
|
||||
try:
|
||||
value = float(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
value = min_v
|
||||
|
||||
span = (max_v - min_v) or 1
|
||||
ratio = max(0.0, min(1.0, (value - min_v) / span))
|
||||
percent = round(ratio * 100, 1)
|
||||
fill_color = _interpolate_color(couleur_basse, couleur_haute, ratio)
|
||||
|
||||
label_html = ""
|
||||
if afficher_valeur:
|
||||
label_html = (
|
||||
f'<div style="position:absolute; inset:0; display:flex; align-items:center; '
|
||||
f'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; '
|
||||
f'color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">{html_lib.escape(str(raw_value if raw_value is not None else "—"))}</div>'
|
||||
)
|
||||
|
||||
# La classe "jaugeBar" est repérée côté client (play.html, refreshRuntimeData)
|
||||
# pour savoir que ce wrapper doit être régénéré après toute action
|
||||
# "Modifier une donnée" — sinon la jauge resterait figée sur sa valeur
|
||||
# de départ après un changement, exactement comme il fallait déjà
|
||||
# repérer "repeaterItem" pour les Répéteurs (voir 1.1/1.2).
|
||||
bar = (
|
||||
f'<div class="jaugeBar" style="position:absolute; left:0; top:0; bottom:0; width:{percent}%; '
|
||||
f'background-color:{fill_color}; transition:width .3s ease, background-color .3s ease;"></div>'
|
||||
)
|
||||
return (
|
||||
f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}" style="{html_lib.escape(style)}">'
|
||||
f'{bar}{label_html}</div>'
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
import html as html_lib
|
||||
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .style_string import _style_string
|
||||
from .render_children import _render_children
|
||||
|
||||
|
||||
def _render_overlay(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
"""3.4 (Confort) — overlay/modale réutilisable : une boîte de dialogue
|
||||
prête à l'emploi, par-dessus TOUT le reste de l'écran, fermeture
|
||||
manuelle uniquement (aucun clic-en-dehors-pour-fermer volontairement,
|
||||
conformément au manque documenté). Contrairement aux autres widgets, sa
|
||||
position ne dépend PAS de x/y/width/height (glissé-déposé sur le
|
||||
canevas) : `position:fixed; inset:0` la fait toujours couvrir tout
|
||||
l'écran, quel que soit l'endroit où elle a été posée dans l'éditeur —
|
||||
seul un voile semi-transparent + une boîte centrée, contenant les
|
||||
éléments posés à l'intérieur (comme un conteneur normal).
|
||||
|
||||
Ouverture/fermeture : PAS de mécanisme dédié — elle réutilise l'action
|
||||
existante "Modifier un élément → Visibilité" (masquer/rendre visible),
|
||||
exactement comme n'importe quel autre élément. C'est pour ça qu'on
|
||||
fusionne le style de positionnement fixe ci-dessous AVEC le style normal
|
||||
de l'élément (_style_string) : si l'élément est réglé "Masqué" dans ses
|
||||
propriétés, ou si une action "Modifier un élément" le cache plus tard,
|
||||
ce masquage continue de fonctionner normalement."""
|
||||
attrs_raw = el.get("attributes") or {}
|
||||
box_color = attrs_raw.get("_couleur_boite") or "#1f2430"
|
||||
radius = attrs_raw.get("_arrondi") or "12"
|
||||
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
base_overlay_style = (
|
||||
"position:fixed; inset:0; z-index:9999; display:flex; "
|
||||
"align-items:center; justify-content:center; background:rgba(0,0,0,0.6);"
|
||||
)
|
||||
style = base_overlay_style + " " + _style_string(el, parent_flex_direction=parent_flex_direction)
|
||||
|
||||
attr_parts = [f'style="{html_lib.escape(style)}"']
|
||||
for k, v in attrs.items():
|
||||
if v in (None, "", False):
|
||||
continue
|
||||
attr_parts.append(
|
||||
html_lib.escape(str(k)) if v is True else f'{html_lib.escape(str(k))}="{html_lib.escape(str(v))}"'
|
||||
)
|
||||
|
||||
box_style = (
|
||||
f"background:{html_lib.escape(str(box_color))}; border-radius:{html_lib.escape(str(radius))}px; "
|
||||
"padding:24px; max-width:90%; max-height:90%; overflow:auto; box-sizing:border-box;"
|
||||
)
|
||||
child_html = _render_children(el, slug, children_map, ctx)
|
||||
return (
|
||||
f'<div {" ".join(attr_parts)}>'
|
||||
f'<div class="forgeOverlayBox" style="{html_lib.escape(box_style)}">{child_html}</div>'
|
||||
f"</div>"
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
import html as html_lib
|
||||
|
||||
import db
|
||||
|
||||
from .style_string import _style_string
|
||||
from .auto_id import _auto_id
|
||||
from .apply_ctx import _apply_ctx
|
||||
from .filter_repeater_rows import filter_repeater_rows
|
||||
from ..element_types.load_template_tree import _load_template_tree
|
||||
|
||||
|
||||
def _render_repeater(el, meta, slug, children_map, ctx=None, parent_flex_direction=None):
|
||||
from .render_element_html import render_element_html
|
||||
|
||||
style = _style_string(el, " overflow:auto;", parent_flex_direction, widget_key=el.get("widget"))
|
||||
attrs_raw = el.get("attributes") or {}
|
||||
definition_id = attrs_raw.get("_definition_id")
|
||||
# data-definition-id sur le Répéteur lui-même : lu côté client (play.html)
|
||||
# pour savoir, au clic sur une de ses lignes, à quel objet de données
|
||||
# appartient la ligne cliquée (voir déclencheur "clic sur une ligne de
|
||||
# Répéteur" et l'action "Ouvrir la ligne cliquée").
|
||||
definition_attr = f' data-definition-id="{html_lib.escape(str(definition_id))}"' if definition_id else ""
|
||||
template_type_id = attrs_raw.get("_template_type_id")
|
||||
template = _apply_ctx(el.get("content") or "", ctx)
|
||||
children = (children_map or {}).get(el["id"], [])
|
||||
if not definition_id or not slug:
|
||||
return f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}" style="{html_lib.escape(style)}"><em>Répéteur : choisis un objet de données dans les propriétés.</em></div>'
|
||||
definition = db.get_definition(slug, int(definition_id))
|
||||
if not definition:
|
||||
return f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}" style="{html_lib.escape(style)}"><em>Objet introuvable — a-t-il été supprimé ?</em></div>'
|
||||
# (definition_attr est ajouté au conteneur du Répéteur plus bas, une fois
|
||||
# qu'on sait que l'objet est valide.)
|
||||
rows = db.list_rows(slug, definition)
|
||||
rows = filter_repeater_rows(slug, definition, rows, attrs_raw)
|
||||
field_cols = {f["name"]: db.slugify(f["name"]).replace("-", "_") for f in definition["fields"]}
|
||||
|
||||
# Modèle de ligne = un élément de jeu (conteneur stylisé réutilisable) :
|
||||
# on recharge son arbre EN DIRECT à chaque ligne de données (jamais copié
|
||||
# en base), et on l'affiche avec les {{champ}} de cette ligne — voir
|
||||
# _load_template_tree pour le détail de cette astuce de rendu.
|
||||
template_tree = None
|
||||
if template_type_id:
|
||||
try:
|
||||
template_tree = _load_template_tree(slug, int(template_type_id))
|
||||
except (TypeError, ValueError):
|
||||
template_tree = None
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
row_ctx = dict(ctx or {})
|
||||
row_ctx.update({fname: str(row.get(col, "") or "") for fname, col in field_cols.items()})
|
||||
if template_tree:
|
||||
inner = "".join(
|
||||
render_element_html(c, slug, template_tree["children_map"], row_ctx)
|
||||
for c in template_tree["top"]
|
||||
)
|
||||
elif children:
|
||||
inner = "".join(render_element_html(c, slug, children_map, row_ctx) for c in children)
|
||||
else:
|
||||
line = template
|
||||
for fname, val in row_ctx.items():
|
||||
line = line.replace("{{" + fname + "}}", val)
|
||||
inner = html_lib.escape(line)
|
||||
# data-row-id : identifiant STABLE de la vraie ligne de données
|
||||
# affichée (par opposition à data-element-id, qui vaut l'id du
|
||||
# modèle de ligne et se répète à l'identique sur chaque ligne) —
|
||||
# c'est ce qui permet au clic de savoir "quel mail" a été ouvert.
|
||||
items.append(f'<div class="repeaterItem" data-row-id="{row["id"]}">{inner}</div>')
|
||||
empty_msg = "Aucune ligne ne correspond au filtre réglé." if attrs_raw.get("_filtre_champ") else "Aucune donnée dans cet objet pour l'instant."
|
||||
inner_all = "".join(items) if items else f"<em>{empty_msg}</em>"
|
||||
return f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}"{definition_attr} style="{html_lib.escape(style)}">{inner_all}</div>'
|
||||
@@ -0,0 +1,15 @@
|
||||
import html as html_lib
|
||||
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .apply_ctx import _apply_ctx
|
||||
from .style_string import _style_string
|
||||
from .attr_string import _attr_string
|
||||
|
||||
|
||||
def _render_select(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
style = _style_string(el, parent_flex_direction=parent_flex_direction)
|
||||
content = _apply_ctx(el.get("content") or "", ctx)
|
||||
options = [o.strip() for o in content.split("\n") if o.strip()]
|
||||
opts_html = "".join(f'<option value="{html_lib.escape(o)}">{html_lib.escape(o)}</option>' for o in options)
|
||||
return f'<select {_attr_string(attrs, style)}>{opts_html}</select>'
|
||||
@@ -0,0 +1,24 @@
|
||||
import html as html_lib
|
||||
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .apply_ctx import _apply_ctx
|
||||
from .style_string import _style_string
|
||||
from .attr_string import _attr_string
|
||||
|
||||
|
||||
def _render_table(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
header_row = (el.get("attributes") or {}).get("_header_row") in ("1", "true", "on")
|
||||
content = _apply_ctx(el.get("content") or "", ctx)
|
||||
style = _style_string(el, " border-collapse:collapse;", parent_flex_direction)
|
||||
rows = [r for r in content.split("\n") if r.strip()]
|
||||
html_rows = []
|
||||
for i, r in enumerate(rows):
|
||||
cells = r.split(";")
|
||||
cell_tag = "th" if (header_row and i == 0) else "td"
|
||||
cells_html = "".join(
|
||||
f'<{cell_tag} style="border:1px solid #2b3140; padding:6px 8px;">{html_lib.escape(c.strip())}</{cell_tag}>'
|
||||
for c in cells
|
||||
)
|
||||
html_rows.append(f"<tr>{cells_html}</tr>")
|
||||
return f'<table {_attr_string(attrs, style)}>{"".join(html_rows)}</table>'
|
||||
@@ -0,0 +1,70 @@
|
||||
from ..widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
|
||||
|
||||
|
||||
def _style_string(el, extra_base="", parent_flex_direction=None, widget_key=None):
|
||||
style = dict(el.get("style") or {})
|
||||
if widget_key in LAYOUT_CAPABLE_WIDGETS and "display" not in style:
|
||||
# "Empilement libre" (valeur par défaut de "Disposition interne")
|
||||
# doit quand même permettre l'espacement (gap) entre les enfants —
|
||||
# or le CSS "gap" n'a aucun effet sur un display bloc classique, il
|
||||
# lui faut un conteneur flex/grid. Tant qu'aucune disposition n'a
|
||||
# été choisie explicitement, on bascule donc ce widget sur un flex
|
||||
# en colonne : visuellement identique à un simple empilement, mais
|
||||
# l'espacement fonctionne dès la création, sans avoir à changer la
|
||||
# disposition interne au préalable.
|
||||
style["display"] = "flex"
|
||||
style.setdefault("flex-direction", "column")
|
||||
is_child = bool(el.get("parent_id"))
|
||||
if is_child:
|
||||
# Élément physiquement posé à l'intérieur d'un conteneur/répéteur/
|
||||
# groupe de champs : une hauteur fixée à 100% se calcule contre la
|
||||
# hauteur du parent et écraserait/déborderait les autres enfants
|
||||
# (le conteneur coupe alors ce qui dépasse -> l'enfant "disparaît").
|
||||
# La hauteur s'adapte donc à son contenu, comme dans une vraie page.
|
||||
# Largeur : dans une disposition en LIGNE (flex-direction row), la
|
||||
# largeur est l'axe principal — si chaque enfant réclame 100% de
|
||||
# cette largeur, ils entrent en collision (l'algorithme flex les
|
||||
# comprime alors tous, jusqu'à les faire quasi disparaître). Dans les
|
||||
# autres cas (empilement libre, ou disposition en COLONNE où la
|
||||
# largeur est l'axe transversal), 100% reste le bon défaut.
|
||||
width_default = "auto" if parent_flex_direction in ("row", "row-reverse") else "100%"
|
||||
base_style = f"width:{width_default}; height:auto; box-sizing:border-box; margin:0;" + extra_base
|
||||
else:
|
||||
base_style = "width:100%; height:100%; box-sizing:border-box; margin:0;" + extra_base
|
||||
|
||||
has_size_override = False
|
||||
|
||||
def _emit(k, v):
|
||||
nonlocal has_size_override
|
||||
if v in (None, ""):
|
||||
return False
|
||||
if k == "transform" and not is_child:
|
||||
# Un élément posé directement sur un écran a déjà ce "transform"
|
||||
# posé sur son cadre .canvasElementInner / .playElementInner
|
||||
# (voir filters/element_style_filter.py:_element_transform_style)
|
||||
# — l'écrire ici aussi (sur la balise intérieure, à l'intérieur
|
||||
# d'un cadre qui reste fixe) donnait l'impression que l'échelle/
|
||||
# l'animation restait "coincée à l'intérieur" au lieu de
|
||||
# s'appliquer à l'élément entier. Un élément posé à l'intérieur
|
||||
# d'un conteneur/répéteur/groupe de champs n'a pas ce cadre
|
||||
# séparé (sa balise EST déjà tout l'élément) : pour lui,
|
||||
# "transform" reste ici.
|
||||
return False
|
||||
if k in ("width", "height") and str(v).strip() in ("0px", "0"):
|
||||
# 0 = réglage "Taille dans le conteneur" laissé à sa valeur par
|
||||
# défaut (voir SIZE_OVERRIDE_CONTROLS) : ne pas l'écrire, pour
|
||||
# garder le comportement automatique de base_style ci-dessus.
|
||||
return False
|
||||
if k in ("width", "height"):
|
||||
has_size_override = True
|
||||
return True
|
||||
|
||||
user_style = "; ".join(f"{k}:{v}" for k, v in style.items() if _emit(k, v))
|
||||
if has_size_override:
|
||||
# Une taille fixe explicite (voir "Taille dans le conteneur") ne doit
|
||||
# pas être écrasée par la compression automatique d'une disposition
|
||||
# flex (flex-shrink vaut 1 par défaut sur tout enfant) — sinon un
|
||||
# élément à qui on a donné une largeur/hauteur précise peut quand
|
||||
# même se retrouver comprimé si ses voisins manquent de place.
|
||||
user_style = (user_style + "; " if user_style else "") + "flex-shrink:0"
|
||||
return base_style + (" " + user_style + ";" if user_style else "")
|
||||
@@ -0,0 +1,4 @@
|
||||
def _trigger_for(widget_key, attributes):
|
||||
if (attributes or {}).get("type") == "submit":
|
||||
return "soumission"
|
||||
return "clic"
|
||||
@@ -0,0 +1,22 @@
|
||||
from .apply_ctx import _apply_ctx
|
||||
from .auto_id import _auto_id
|
||||
|
||||
|
||||
def _visible_attrs(el, meta, ctx=None):
|
||||
attrs = {
|
||||
k: (_apply_ctx(v, ctx) if isinstance(v, str) else v)
|
||||
for k, v in (el.get("attributes") or {}).items() if not k.startswith("_")
|
||||
}
|
||||
attrs.update(meta.get("fixed_attrs", {}))
|
||||
attrs.setdefault("id", _auto_id(el))
|
||||
attrs["data-element-id"] = el["id"]
|
||||
if el.get("parent_id"):
|
||||
# Élément posé à l'intérieur d'un conteneur/répéteur/groupe de
|
||||
# champs : pas de cadre de positionnement séparé autour de lui (voir
|
||||
# element_style_filter.py) — sa propre balise EST déjà tout
|
||||
# l'élément, donc c'est elle que la timeline d'animation doit viser.
|
||||
# Un élément posé directement sur un écran, lui, porte ce marqueur
|
||||
# sur son cadre (.canvasElement / .playElement) à la place — voir
|
||||
# les gabarits screen_edit.html et play.html.
|
||||
attrs["data-anim-target"] = el["id"]
|
||||
return attrs
|
||||
Reference in New Issue
Block a user