Nouveau groupe "Ombre" dans le panneau de propriétés, à côté de "Bordure" (même universalité — voir UNIVERSAL_CONTROLS) : décalage X/Y, flou, étendue, couleur et opacité, combinés en une seule valeur CSS box-shadow (couleur+opacité fusionnées en rgba(), un <input type="color"> seul ne portant pas de canal alpha). Décalages/flou/étendue tous à 0 = pas d'ombre, même convention que border-width à 0 = pas de bordure. Nouveau ctype "shadow" (c_shadow.py), suit exactement le même principe que le ctype "size" déjà existant (size_override_controls.py) : plusieurs entrées de formulaire pour un seul réglage, composées/décomposées dans save_element_controls.py et control_value.py plutôt que passées par le chemin générique clé->valeur. Ajoute tests/test_shadow_controls.py (présence dans le panneau, composition de la valeur CSS, aller-retour dans le formulaire, remise à zéro qui retire l'ombre). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
182 lines
7.4 KiB
Python
182 lines
7.4 KiB
Python
import json
|
|
|
|
import db
|
|
|
|
from ..widgets.widget_meta import widget_meta
|
|
from .get_element import get_element
|
|
|
|
|
|
def _hex_to_rgb(hex_color):
|
|
h = (hex_color or "").lstrip("#")
|
|
if len(h) != 6:
|
|
return (0, 0, 0)
|
|
try:
|
|
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
|
|
except ValueError:
|
|
return (0, 0, 0)
|
|
|
|
|
|
def _is_default_value(control, raw_value):
|
|
"""Compare la valeur reçue du formulaire à la valeur par défaut du
|
|
réglage (en tenant compte de l'unité ajoutée pour les sliders), pour
|
|
savoir si le champ a simplement été renvoyé tel quel sans être
|
|
personnalisé."""
|
|
default = control.get("default", "")
|
|
if control["type"] == "slider":
|
|
unit = control.get("unit", "")
|
|
default = f"{default}{unit}" if default not in (None, "") else ""
|
|
return str(raw_value) == str(default)
|
|
|
|
|
|
def save_element_controls(slug, element_id, form):
|
|
"""Lit, pour chaque réglage du widget de cet élément, la valeur envoyée
|
|
par le panneau de propriétés (un seul formulaire, un champ par réglage)
|
|
et met à jour content / attributs / style en conséquence."""
|
|
el = get_element(slug, element_id)
|
|
if not el:
|
|
return
|
|
meta = widget_meta(el["widget"])
|
|
content = el["content"]
|
|
attributes = dict(el["attributes"])
|
|
style = dict(el["style"])
|
|
toggle_groups = {} # propriété CSS -> ensemble des "tokens" cochés (ex: text-decoration)
|
|
|
|
for control in meta.get("controls", []):
|
|
ctype = control["type"]
|
|
field_name = "ctrl_" + control["key"]
|
|
|
|
if ctype == "toggle_token":
|
|
_kind, name = control["target"].split(":", 1)
|
|
toggle_groups.setdefault(name, set())
|
|
if form.get(field_name) is not None:
|
|
toggle_groups[name].add(control["token"])
|
|
continue
|
|
|
|
if ctype == "preset":
|
|
chosen = form.get(field_name, control.get("default"))
|
|
opt = next((o for o in control["options"] if o[0] == chosen), None)
|
|
if opt:
|
|
for k, v in opt[2].items():
|
|
if v in (None, ""):
|
|
style.pop(k, None)
|
|
else:
|
|
style[k] = v
|
|
continue
|
|
|
|
if ctype == "scale":
|
|
try:
|
|
val = float(form.get(field_name, control.get("default", 1)))
|
|
except (TypeError, ValueError):
|
|
val = 1.0
|
|
if abs(val - 1) < 1e-6:
|
|
style.pop("transform", None)
|
|
else:
|
|
style["transform"] = f"scale({val})"
|
|
continue
|
|
|
|
if ctype == "size":
|
|
# Un seul champ CSS ("width"/"height") mais deux entrées de
|
|
# formulaire (valeur + unité) — voir size_override_controls.py
|
|
# pour pourquoi ce n'est PAS deux contrôles slider séparés.
|
|
_kind, name = control["target"].split(":", 1)
|
|
try:
|
|
num = float(form.get(field_name + "_value") or 0)
|
|
except (TypeError, ValueError):
|
|
num = 0.0
|
|
unit = form.get(field_name + "_unit", "%")
|
|
if unit not in ("px", "%"):
|
|
unit = "%"
|
|
if num <= 0:
|
|
style.pop(name, None)
|
|
else:
|
|
num_str = str(int(num)) if num == int(num) else str(num)
|
|
style[name] = f"{num_str}{unit}"
|
|
continue
|
|
|
|
if ctype == "shadow":
|
|
# 6 entrées de formulaire (décalage X/Y, flou, étendue, couleur,
|
|
# opacité — voir c_shadow.py) combinées en une seule valeur CSS
|
|
# "box-shadow" (couleur+opacité fusionnées en rgba(), un
|
|
# <input type="color"> seul ne portant pas de canal alpha).
|
|
_kind, name = control["target"].split(":", 1)
|
|
defaults = control.get("default", {})
|
|
|
|
def _num(suffix, fallback):
|
|
try:
|
|
return float(form.get(field_name + suffix, fallback))
|
|
except (TypeError, ValueError):
|
|
return fallback
|
|
|
|
x = _num("_x", defaults.get("x", 0))
|
|
y = _num("_y", defaults.get("y", 4))
|
|
blur = _num("_blur", defaults.get("blur", 12))
|
|
spread = _num("_spread", defaults.get("spread", 0))
|
|
opacity = _num("_opacity", defaults.get("opacity", 0.4))
|
|
color = form.get(field_name + "_color") or defaults.get("color", "#000000")
|
|
if x == 0 and y == 0 and blur == 0 and spread == 0:
|
|
style.pop(name, None)
|
|
else:
|
|
r, g, b = _hex_to_rgb(color)
|
|
|
|
def _px(n):
|
|
return str(int(n)) if n == int(n) else str(n)
|
|
|
|
style[name] = f"{_px(x)}px {_px(y)}px {_px(blur)}px {_px(spread)}px rgba({r}, {g}, {b}, {opacity})"
|
|
continue
|
|
|
|
target = control["target"]
|
|
if ctype == "checkbox":
|
|
checked = form.get(field_name) is not None
|
|
raw_value = control["true_value"] if checked else control["false_value"]
|
|
else:
|
|
raw_value = form.get(field_name, control.get("default", ""))
|
|
if ctype == "slider":
|
|
unit = control.get("unit", "")
|
|
raw_value = f"{raw_value}{unit}" if raw_value not in (None, "") else ""
|
|
|
|
if target == "content":
|
|
content = raw_value
|
|
else:
|
|
kind, name = target.split(":", 1)
|
|
if kind == "class":
|
|
# "class:xxx" : un jeton de classe CSS externe (ex. un
|
|
# modificateur Bulma) parmi d'autres, chacun mémorisé sous sa
|
|
# propre clé "_class_xxx" pour ne pas s'écraser entre eux —
|
|
# voir _visible_attrs.py qui les rassemble avec la classe de
|
|
# base du widget (fixed_attrs) au moment du rendu.
|
|
store, name = attributes, "_class_" + name
|
|
else:
|
|
store = attributes if kind == "attr" else style
|
|
if raw_value in (None, ""):
|
|
store.pop(name, None)
|
|
elif ctype != "checkbox" and name not in store and _is_default_value(control, raw_value):
|
|
# Ce réglage n'a jamais été personnalisé (la clé est absente
|
|
# du style/des attributs stockés) et la valeur reçue est
|
|
# simplement celle par défaut affichée dans le champ (un
|
|
# <input type="color"> ou un slider a TOUJOURS une valeur à
|
|
# l'écran, même quand rien n'a été choisi). Comme le
|
|
# panneau de propriétés envoie TOUS les réglages du widget
|
|
# en un seul formulaire, sauvegarder n'importe quel autre
|
|
# champ (ex. changer la disposition) écrirait sinon cette
|
|
# valeur par défaut "en dur" dans l'élément — ce qui faisait
|
|
# apparaître par exemple une couleur de fond qui n'avait
|
|
# jamais été demandée. On laisse donc ce réglage "non
|
|
# défini" tant qu'il n'a pas été explicitement changé.
|
|
pass
|
|
else:
|
|
store[name] = raw_value
|
|
|
|
for prop, tokens in toggle_groups.items():
|
|
if tokens:
|
|
style[prop] = " ".join(sorted(tokens))
|
|
else:
|
|
style.pop(prop, None)
|
|
|
|
conn = db.connect(slug)
|
|
conn.execute(
|
|
"UPDATE _screen_elements SET content = ?, attributes = ?, style = ? WHERE id = ?",
|
|
(content, json.dumps(attributes), json.dumps(style), element_id),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|