Dev #1

Merged
w-vandal merged 62 commits from dev into main 2026-08-26 04:56:44 +00:00
7 changed files with 249 additions and 44 deletions
Showing only changes of commit bb79f2f93d - Show all commits
+1
View File
@@ -4,5 +4,6 @@ from . import (
element_geometry,
element_save,
element_delete,
element_duplicate,
element_rename,
)
+14
View File
@@ -0,0 +1,14 @@
from flask import redirect, url_for, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/duplicate", methods=["POST"])
def element_duplicate(slug, element_id):
element = screens.get_element(slug, element_id)
if not element:
abort(404)
new_id = screens.duplicate_element(slug, element_id)
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=new_id))
+1
View File
@@ -76,6 +76,7 @@ from .elements.add_element import add_element
from .elements.update_element_geometry import update_element_geometry
from .elements.save_element_controls import save_element_controls
from .elements.delete_element import delete_element
from .elements.duplicate_element import duplicate_element
from .elements.rename_element import rename_element
from .elements.element_label import element_label
+60
View File
@@ -0,0 +1,60 @@
import db
def duplicate_element(slug, element_id):
"""Duplique un élément et tout son sous-arbre DANS SON CONTENEUR ACTUEL
(même écran, même parent_id que l'original) — le clic droit "Dupliquer"
de l'arborescence n'a pas à faire choisir où poser la copie, elle
apparaît juste à côté de l'original. Renvoie l'id de la copie (celui à
sélectionner ensuite), ou None si l'élément n'existe pas."""
conn = db.connect(slug)
root_row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
if not root_row:
conn.close()
return None
root = dict(root_row)
source = [
dict(r) for r in conn.execute(
"SELECT * FROM _screen_elements WHERE screen_id = ?", (root["screen_id"],)
).fetchall()
]
conn.close()
def copy_one(src, new_parent_id, is_root):
conn = db.connect(slug)
max_z = conn.execute(
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (src["screen_id"],)
).fetchone()["m"]
x, y = src["x"], src["y"]
if is_root and not new_parent_id:
# Petit décalage pour que la copie ne recouvre pas exactement
# l'original (qui serait alors invisible en dessous).
x = min(100, (x or 0) + 4)
y = min(100, (y or 0) + 4)
name = src.get("name")
if is_root and name:
name = f"{name} (copie)"
conn.execute(
"""INSERT INTO _screen_elements
(screen_id, parent_id, widget, element_type_id, name, content, attributes,
x, y, width, height, z_index, style)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
src["screen_id"], new_parent_id, src["widget"], src.get("element_type_id"), name,
src["content"], src["attributes"], x, y, src["width"], src["height"],
(max_z or 0) + 1, src["style"],
),
)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return new_id
def walk(src_parent_id, new_parent_id):
for child in [s for s in source if s.get("parent_id") == src_parent_id]:
new_child_id = copy_one(child, new_parent_id, is_root=False)
walk(child["id"], new_child_id)
new_root_id = copy_one(root, root.get("parent_id"), is_root=True)
walk(root["id"], new_root_id)
return new_root_id
+27
View File
@@ -154,6 +154,33 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
.childRow{ padding:7px 10px; font-size:12px; }
.childRow .rowTitle{ font-size:12px; }
/* Arborescence compacte des éléments de l'écran (panneau gauche) — une
ligne par élément, indentation par imbrication réelle (profondeur
illimitée) plutôt qu'un seul niveau d'enfants comme l'ancienne .childList,
pour rester lisible même avec beaucoup d'éléments. */
.elementTree{ display:flex; flex-direction:column; gap:1px; max-height:55vh; overflow-y:auto; margin-bottom:14px; }
.treeNode{ display:flex; flex-direction:column; }
.treeRowLink{
display:flex; align-items:center; gap:6px; padding:4px 6px; border-radius:5px;
text-decoration:none; color:var(--text); font-size:12.5px; line-height:1.5;
white-space:nowrap; overflow:hidden;
}
.treeRowLink:hover{ background:var(--panel2); }
.treeRowLink.selected{ background:var(--accent); color:#0b0e14; font-weight:600; }
.treeIcon{ flex:0 0 auto; font-size:12px; }
.treeLabel{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.treeChildren{ display:flex; flex-direction:column; margin-left:10px; padding-left:8px; border-left:1px dashed var(--border); }
.contextMenu{
position:fixed; z-index:500; background:var(--panel); border:1px solid var(--border);
border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.4); padding:4px; min-width:160px;
}
.contextMenu button{
display:block; width:100%; text-align:left; background:none; border:none; color:var(--text);
padding:7px 10px; border-radius:5px; font-size:13px; cursor:pointer;
}
.contextMenu button:hover{ background:var(--panel2); }
.propGroup{ border:1px solid var(--border); border-radius:8px; margin-bottom:8px; background:var(--panel); }
.propGroup > summary{
list-style:none; cursor:pointer; padding:9px 12px; font-size:13px; color:var(--accent);
+82 -44
View File
@@ -29,16 +29,31 @@
</p>
{% endif %}
{% macro render_tree_node(el) %}
<div class="treeNode">
<a class="treeRowLink {{ 'selected' if el.id == selected_id else '' }}"
href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=el.id) }}"
data-element-id="{{ el.id }}"
oncontextmenu="return showElementContextMenu(event, {{ el.id }});">
<span class="treeIcon">{{ widgets.get(el.widget, {}).get('icon', '❔') }}</span>
<span class="treeLabel">{{ el|elabel }}</span>
</a>
{% set kids = children_of.get(el.id, []) %}
{% if kids %}
<div class="treeChildren">
{% for child in kids %}
{{ render_tree_node(child) }}
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}
<div class="builder3" id="builder3">
{% set el = elements|selectattr("id", "equalto", selected_id)|first if selected_id else none %}
<!-- Panneau gauche : ajouter un élément (icônes, sans jargon technique) -->
<div class="builderPanel">
{% if selected_id and el and el.widget in container_widgets %}
<p class="hint" style="background:#2a1f10; border:1px solid #6b4a12; border-radius:8px; padding:8px 12px; margin-bottom:10px;">
⚠️ Un conteneur est sélectionné. La grille ci-dessous pose l'élément directement sur l'écran, PAS à l'intérieur du conteneur — pour poser un élément dans le conteneur sélectionné, utilise plutôt <strong>« 📥 Ajouter DANS ce conteneur »</strong> dans le panneau de droite.
</p>
{% endif %}
<details class="propGroup" open>
<details class="propGroup">
<summary>Ajouter un élément sur l'écran</summary>
<div class="propGroupBody">
<div class="widgetGrid">
@@ -59,7 +74,7 @@
Outil, IA...) : chacun a désormais SA PROPRE tuile, avec son nom,
plutôt qu'une unique tuile générique "Élément de jeu" qui ajoutait
toujours le premier du catalogue sans laisser choisir lequel. -->
<details class="propGroup" {{ 'open' if element_types else '' }}>
<details class="propGroup">
<summary>🧩 Mes éléments de jeu{{ ' (' ~ element_types|length ~ ')' if element_types else '' }}</summary>
<div class="propGroupBody">
{% if element_types %}
@@ -83,24 +98,9 @@
<h2>Éléments de cet écran</h2>
{% if top_level_elements %}
<div class="list">
<div class="elementTree">
{% for el in top_level_elements %}
<a class="listRow {{ 'selected' if el.id == selected_id else '' }}" id="listrow-{{ el.id }}"
href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=el.id) }}">
<div class="rowTitle">{{ widgets.get(el.widget, {}).get('icon', '❔') }} {{ el|elabel }}</div>
<div class="rowSub">{{ (el.content or '—')[:30] }}</div>
</a>
{% if el.widget in container_widgets and children_of.get(el.id) %}
<div class="childList">
{% for child in children_of.get(el.id) %}
<a class="listRow childRow {{ 'selected' if child.id == selected_id else '' }}"
href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=child.id) }}">
<div class="rowTitle">↳ {{ widgets.get(child.widget, {}).get('icon', '❔') }} {{ child|elabel }}</div>
<div class="rowSub">{{ (child.content or '—')[:26] }}</div>
</a>
{% endfor %}
</div>
{% endif %}
{{ render_tree_node(el) }}
{% endfor %}
</div>
{% else %}
@@ -108,6 +108,11 @@
{% endif %}
</div>
<!-- Menu contextuel (clic droit sur un élément de l'arborescence) -->
<div id="elementContextMenu" class="contextMenu" style="display:none;">
<button type="button" onclick="contextMenuDuplicate()">📄 Dupliquer</button>
</div>
<!-- Centre : l'écran, en très grand -->
<div class="builderCanvasArea">
{% set current_aspect = screen.preview_aspect or '9/16' %}
@@ -156,23 +161,9 @@
</div>
{% if el.widget in container_widgets %}
<details class="propGroup containerContentGroup" open>
<details class="propGroup containerContentGroup">
<summary>📥 Ajouter DANS ce conteneur</summary>
<div class="propGroupBody">
{% set children = children_of.get(el.id, []) %}
{% if children %}
<div class="list">
{% for child in children %}
<a class="listRow childRow {{ 'selected' if child.id == selected_id else '' }}"
href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=child.id) }}">
<div class="rowTitle">↳ {{ widgets.get(child.widget, {}).get('icon', '❔') }} {{ child|elabel }}</div>
<div class="rowSub">{{ (child.content or '—')[:26] }}</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="hint">Aucun élément à l'intérieur pour l'instant — ajoute-en un ci-dessous.</p>
{% endif %}
<div class="widgetGrid">
{% for wkey, w in widgets.items() %}
<form method="post" action="{{ url_for('element_add_child', slug=game.slug, parent_id=el.id) }}" class="widgetBtnForm">
@@ -193,7 +184,7 @@
{% for c in selected_controls if c.key in size_ctrl_keys %}{% set _ = size_ctrls.update({c.key: c}) %}{% endfor %}
<form method="post" action="{{ url_for('element_save', slug=game.slug, element_id=el.id) }}" id="propsForm" onsubmit="event.preventDefault(); return false;">
<details class="propGroup" open>
<details class="propGroup">
<summary>Position &amp; taille</summary>
<div class="propGroupBody">
<div class="posGrid">
@@ -236,7 +227,7 @@
</div>
</details>
{% endif %}
<details class="propGroup" {{ 'open' if not ns.started else '' }}>
<details class="propGroup">
<summary>{{ c.group or 'Propriétés' }}</summary>
<div class="propGroupBody">
{% set ns.last_group = c.group %}
@@ -587,6 +578,53 @@ function saveGeometry(id, x, y, width, height) {
var SCREEN_EDIT_URL = "{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id) }}";
var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};
var GAME_SLUG = "{{ game.slug }}";
// ---------- Menu contextuel (clic droit) de l'arborescence des éléments ----------
//
// Une seule action pour l'instant : dupliquer l'élément dans son conteneur
// actuel (même parent, ou même écran si posé directement dessus) — voir
// screens/elements/duplicate_element.py. La duplication passe par un vrai
// <form method="post"> soumis dynamiquement : pjax.js intercepte tous les
// formulaires internes, donc pas besoin de gérer le fetch/la redirection
// nous-mêmes ici.
var contextMenuElementId = null;
function showElementContextMenu(event, elementId) {
event.preventDefault();
contextMenuElementId = elementId;
var menu = document.getElementById('elementContextMenu');
menu.style.left = event.pageX + 'px';
menu.style.top = event.pageY + 'px';
menu.style.display = 'block';
return false;
}
function hideElementContextMenu() {
var menu = document.getElementById('elementContextMenu');
if (menu) menu.style.display = 'none';
contextMenuElementId = null;
}
function contextMenuDuplicate() {
var id = contextMenuElementId;
hideElementContextMenu();
if (!id) return;
var form = document.createElement('form');
form.method = 'post';
form.action = '/game/' + GAME_SLUG + '/elements/' + id + '/duplicate';
document.body.appendChild(form);
form.requestSubmit();
}
if (!window.__forgeContextMenuBound) {
window.__forgeContextMenuBound = true;
document.addEventListener('click', function (e) {
var menu = document.getElementById('elementContextMenu');
if (menu && menu.style.display === 'block' && !menu.contains(e.target)) hideElementContextMenu();
});
document.addEventListener('scroll', hideElementContextMenu, true);
}
// ---------- Navigation dans l'éditeur sans AUCUN rechargement de page ----------
//
@@ -636,9 +674,9 @@ function refreshCanvasOnly() {
// Aperçu du contenu dans l'arborescence de gauche (ex: texte tronqué) —
// sans toucher au panneau de droite, pour ne jamais faire perdre le
// focus/curseur pendant qu'on modifie un réglage.
var newList = doc.querySelector('.builderPanel .list');
var curList = document.querySelector('.builderPanel .list');
if (newList && curList) curList.innerHTML = newList.innerHTML;
var newTree = doc.querySelector('.builderPanel .elementTree');
var curTree = document.querySelector('.builderPanel .elementTree');
if (newTree && curTree) curTree.innerHTML = newTree.innerHTML;
});
}
+64
View File
@@ -182,6 +182,70 @@ def test_image_gets_object_fit_cover_by_default(client, game):
assert "object-fit:cover" in html
def test_duplicate_top_level_element_creates_independent_copy(client, game):
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "titre"})
original_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(f"/game/{game}/elements/{original_id}/save", data={"ctrl_content": "Bienvenue"})
resp = client.post(f"/game/{game}/elements/{original_id}/duplicate", follow_redirects=False)
assert resp.status_code == 302
new_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
assert new_id != original_id
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert html.count("Bienvenue") == 2 # original + copie, chacun un élément séparé
# Modifier la copie ne doit pas toucher l'original (deux lignes indépendantes).
client.post(f"/game/{game}/elements/{new_id}/save", data={"ctrl_content": "Copie modifiée"})
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert "Bienvenue" in html
assert "Copie modifiée" in html
def test_duplicate_container_deep_copies_children(client, game):
"""Régression : dupliquer un conteneur doit copier tout son sous-arbre
(pas juste la boîte vide) — voir screens/elements/duplicate_element.py,
qui réutilise le même parcours récursif que la copie d'écran-modèle."""
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"})
container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
resp = client.post(f"/game/{game}/elements/{container_id}/children/add", data={"widget": "titre"})
child_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(f"/game/{game}/elements/{child_id}/save", data={"ctrl_content": "Titre enfant"})
resp = client.post(f"/game/{game}/elements/{container_id}/duplicate", follow_redirects=False)
new_container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
assert new_container_id != container_id
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert html.count("Titre enfant") == 2 # l'enfant a bien été copié avec son parent
def test_duplicate_child_element_stays_in_same_container(client, game):
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"})
container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
resp = client.post(f"/game/{game}/elements/{container_id}/children/add", data={"widget": "texte"})
child_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(f"/game/{game}/elements/{child_id}/duplicate")
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert html.count("↳") == 0 # l'ancienne arborescence imbriquée dans "Ajouter DANS ce conteneur" a disparu
tree_html = html[html.find('class="elementTree"'):]
assert tree_html.count("treeChildren") >= 1 # le conteneur a bien 2 enfants imbriqués (le nouveau + l'original)
def test_screen_editor_property_panels_closed_by_default(client, game):
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"})
container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={container_id}").data.decode()
assert "<details class=\"propGroup\" open>" not in html
assert "Un conteneur est sélectionné" not in html
def test_delete_element_type_removes_hidden_template_screen(client, game):
resp = client.post(f"/game/{game}/element-types", data={"name": "ToDelete", "icon": "🧩"}, follow_redirects=False)
html = client.get(f"/game/{game}/element-types").data.decode()