Supprime la couche de formes libres, déplace la navigation de page dans le panneau gauche
Build and deploy / test-python (push) Successful in 7m43s
Build and deploy / test-js (push) Successful in 57s
Build and deploy / lint-python (push) Successful in 5m29s
Build and deploy / lint-js (push) Failing after 1m16s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 5m42s
Build and deploy / test-python (push) Successful in 7m43s
Build and deploy / test-js (push) Successful in 57s
Build and deploy / lint-python (push) Successful in 5m29s
Build and deploy / lint-js (push) Failing after 1m16s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 5m42s
Formes libres (rectangle/cercle/triangle/trait) retirées de bout en bout (bibliothèque, rendu, panneau Propriétés, grille d'accroche, JS/CSS associés) — fonctionnalité non retenue. La bande de vignettes visuelles des pages au-dessus du canevas est remplacée par une section "Pages" dans le panneau de gauche (liste simple : ajouter/renommer/réordonner (haut/bas)/supprimer), à la place de l'ex-catégorie "Mise en page" de la bibliothèque. La route document_edit ne rend plus qu'une seule page (celle affichée) au chargement, au lieu de toutes les pages pour alimenter les anciennes vignettes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
57c4de3d8a
commit
8dc4b35dcf
+2
-2
@@ -141,7 +141,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq
|
||||
| `static/game/js/scenes/scene-editor.js:214` (`CURRENT_SELECTED_ID = null`), `static/game/js/screen_edit/tree-panels.js:392` (`CURRENT_SELECTED_ID = selectedId \|\| null`) | `javascript:S2703` | Déclarée en **`var`** (pas `let`/`const`) dans `templates/game/scene_edit.html:833` (`var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};`), chargé avant `tree-panels.js`/`scene-editor.js`/`trigger-editor.js`/`collision-rules-editor.js` (ordre vérifié, lignes 833/858/880/885/887/888) — un `var` de script classique attache directement à `window`, réassignable sans aucune restriction depuis n'importe quel autre `<script>` de la page (contrairement au cas `SCENE_OBJECT_NAMES` ci-dessous, qui lui était en `const`). Déjà documenté dans `.eslintrc.json` (`"CURRENT_SELECTED_ID": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 |
|
||||
| `static/game/js/scenes/collision-rules-editor.js:78` (`let _collisionWizard = null;`) et ses réassignations dans ce fichier, **et** `static/game/js/triggers/trigger-editor.js:787,937` (`triggerOpenAppendActionModal`/`screenTriggerOpenAppendActionModal`, `_collisionWizard = { bodyEl: body };` sans mot-clé) | `javascript:S2703` | Même pattern de partage inter-scripts que `SCENE_OBJECT_NAMES` : `trigger-editor.js` réutilise TELLES QUELLES les étapes de l'assistant de `collision-rules-editor.js` (`renderCollisionWizardChainStep`/`collisionWizardBuildLeafAction`, qui ne lisent que `.bodyEl` — voir commentaire ligne 784-786 de `trigger-editor.js`) pour poser "+ Ajouter une action" sur un déclencheur déjà existant, plutôt que de dupliquer ces étapes. `trigger-editor.js` est chargé AVANT `collision-rules-editor.js` (`templates/game/scene_edit.html:887-888`), mais sans risque de TDZ : les deux réassignations de `trigger-editor.js` sont à l'intérieur de fonctions déclenchées par un clic utilisateur, jamais exécutées avant que `collision-rules-editor.js` (et son `let _collisionWizard = null;`) n'ait fini de se charger. Déjà documenté dans `.eslintrc.json` (`"_collisionWizard": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 ; complété lot 4, 18/09/2026 |
|
||||
| `static/game/js/triggers/trigger-editor.js:28` (`let SCENE_OBJECT_NAMES = ...`) | `javascript:S2703` | **Bug réel trouvé et corrigé** (pas un faux positif comme les 4 sites ci-dessus) : était déclarée en `const`, alors que `refreshSceneObjectNames()` (`static/game/js/scenes/scene-editor.js:754-759`) la réassigne après un fetch — deux `<script>` classiques sur la même page partagent un même environnement lexical global, mais une liaison `const` posée dans l'un ne peut pas être réassignée depuis l'autre (`TypeError: Assignment to constant variable.`, reproduit empiriquement via `node:vm`). Symptôme : renommer un personnage puis ouvrir un dialogue de déclencheur sans recharger la page ne montrait jamais le nouveau nom dans "qui parle". Corrigé en `let`, couvert par un test de non-régression (`static/game/js/scenes/__tests__/collision-rules-editor.test.js`, test `refreshSceneObjectNames`) qui échoue avec `TypeError` sur l'ancien code et passe avec le nouveau. | Lot 2 "modernisation JS", 16/09/2026 |
|
||||
| 32 sites `\|safe` (`templates/game/scene_edit.html`, `templates/game/play.html`, `templates/auth/register_2fa.html`, `templates/onboarding/onboarding_new.html`, `templates/game_dashboard_simple.html`, `templates/document/document_edit.html`) + 1 site dans une boucle `{% for %}` de `document_edit.html` (`rendered_pages[page.id]`, une itération par page du support — nombre variable, ajouté le 21/09/2026 pour les vignettes miniatures de la bande de pages) | `Web:S5247` (Sonar) | **Faux positif confirmé sur le fond, mais non-supprimable techniquement pour l'instant.** 3 sous-groupes : (1) `rendered_html`/`qr_svg`/`description` — HTML déjà échappé côté Python (`html.escape()`) ou généré sans texte libre utilisateur ; (2) 25 sites `*_json` — `db.json_for_script()` échappe déjà `</script>` (voir Phase 3) ; (3) `rendered_document`/`rendered_pages[page.id]` (`document_edit.html`, le second ajouté le 21/09/2026) — même sous-groupe (1) : produits par `document_engine.render_document`, qui échappe (`html.escape()`) tout contenu utilisateur avant interpolation (voir `document_engine/rendering/render_document_element.py`) — `rendered_pages[page.id]` rend simplement CHAQUE page du support de la même façon, pour construire une vraie vignette miniature de son contenu réel (retour utilisateur du 21/09/2026 : "je préfère avoir des miniatures"), jamais une nouvelle source de contenu non échappé. Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un `<script>`, bonne position de ligne) : **aucune ne fonctionne** avec l'analyseur Web de cette version de SonarQube. La résolution "Faux positif" via l'API est bloquée par le système de permissions de session. `sonar.issue.ignore.multicriteria` existe mais sans sélecteur de ligne (exclusion fichier entier uniquement) — écarté pour `scene_edit.html`/`play.html` (masquerait un futur `\|safe` réellement dangereux). Ces sites restent visibles dans le rapport Sonar en l'état ; traités et compris, pas un point ouvert côté code. | Phase 3 + investigation du 16/09/2026 ; complété le 20/09/2026 et le 21/09/2026 |
|
||||
| 31 sites `\|safe` (`templates/game/scene_edit.html`, `templates/game/play.html`, `templates/auth/register_2fa.html`, `templates/onboarding/onboarding_new.html`, `templates/game_dashboard_simple.html`, `templates/document/document_edit.html`) | `Web:S5247` (Sonar) | **Faux positif confirmé sur le fond, mais non-supprimable techniquement pour l'instant.** 3 sous-groupes : (1) `rendered_html`/`qr_svg`/`description` — HTML déjà échappé côté Python (`html.escape()`) ou généré sans texte libre utilisateur ; (2) 25 sites `*_json` — `db.json_for_script()` échappe déjà `</script>` (voir Phase 3) ; (3) `rendered_document` (`document_edit.html`) — même sous-groupe (1) : produit par `document_engine.render_document`, qui échappe (`html.escape()`) tout contenu utilisateur avant interpolation (voir `document_engine/rendering/render_document_element.py`). Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un `<script>`, bonne position de ligne) : **aucune ne fonctionne** avec l'analyseur Web de cette version de SonarQube. La résolution "Faux positif" via l'API est bloquée par le système de permissions de session. `sonar.issue.ignore.multicriteria` existe mais sans sélecteur de ligne (exclusion fichier entier uniquement) — écarté pour `scene_edit.html`/`play.html` (masquerait un futur `\|safe` réellement dangereux). Ces sites restent visibles dans le rapport Sonar en l'état ; traités et compris, pas un point ouvert côté code. | Phase 3 + investigation du 16/09/2026 ; complété le 20/09/2026 et le 21/09/2026 |
|
||||
| `static/game/js/play/offline/filter-repeater-rows.js:10,11`, `static/game/js/screen_edit/panel-init.js:254,261`, `static/game/js/play/offline/xapi-client.js:132` | `javascript:S8786` (ReDoS) | 3 regex distinctes (2 dupliquées dans 2 fichiers) testées empiriquement, aucune ne montre de backtracking super-linéaire réel — voir le détail complet juste en dessous du tableau (méthode reproductible). | Lot 1 "modernisation JS", 16/09/2026 |
|
||||
| `static/game/js/play/dialogue-box-controller.js` (`forgeShowQuizBox`, ligne du `void widget.offsetWidth;`) | `javascript:S3735` | Force une lecture de mise en page (reflow) AVANT de reposer la classe `is-active`, pour que l'animation CSS d'entrée du quiz rejoue même si la boîte était déjà active juste avant (2 questions à la suite) — idiome JS standard, `void` marque explicitement une expression dont seul l'EFFET DE LECTURE compte, jamais la valeur. 3 formes essayées dans l'ordre, chacune rejetée par une règle Sonar différente : `void widget.offsetWidth;` (S3735, "retirer void") → `widget.offsetWidth;` seule (S905, "expression sans effet — accepté par ESLint ici, `no-unused-expressions` est désactivé dans ce projet, mais pas par Sonar") → `const _ = widget.offsetWidth;` (S1481, "variable jamais lue — accepté par ESLint via `varsIgnorePattern: ^_$`, pas par Sonar"). Aucune forme ne satisfait Sonar sans en recréer une autre : `void` restauré (la plus lisible/idiomatique des 3, et la seule aussi acceptée par ESLint) et documenté ici plutôt que de continuer à faire tourner ce carrousel. | Lot 4 "modernisation JS", 18/09/2026 |
|
||||
| `static/game/js/screen_edit/tree-panels.js:32` (`restoreTreeCollapsedState`), `:57` (`saveFloatPanelState`), `:230` (sauvegarde état replié/déplié au clic) | `javascript:S2486` | Lecture/écriture `localStorage` purement cosmétique (éditeur seulement, jamais le jeu) : un échec (quota, storage désactivé) laisse au pire un panneau à sa position par défaut ou un nœud d'arborescence dans son état précédent — aucune donnée de jeu en jeu, aucun état perdu de façon irréversible. | Lot 3 "modernisation JS", 16/09/2026 |
|
||||
@@ -152,7 +152,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq
|
||||
| `static/game/js/play/offline/filter-repeater-rows.js:24` (`forgeDecodeClauses`) | `javascript:S2486` | **Corrigé, même patron que ci-dessus** : `_filtres_json` est un attribut rendu par le serveur, jamais tapé à la main — un JSON invalide y trahit presque toujours un bug côté éditeur/serveur. `console.warn('_filtres_json invalide, filtre ignoré', e)` ajouté, comportement inchangé (repli sur l'ancien format à 2 filtres fixes ou aucun filtre). Couvert par un nouveau test (`filter-repeater-rows.test.js`, `forgeDecodeClauses — _filtres_json invalide`). | Lot 4 "modernisation JS", 18/09/2026 |
|
||||
| `static/game/js/play/offline/filter-repeater-rows.js:59`, `:70` (`forgeResolveVariablePath`, JSON.parse + navigation `.champ`/`[index]`) | `javascript:S2486` | **Documenté, pas corrigé — nature différente du cas ci-dessus** : ici `rawValue` est la VALEUR ACTUELLE d'une variable de jeu (modifiable librement par n'importe quelle action "Modifier une variable"), pas une config interne à l'éditeur — un chemin qui ne correspond pas à sa forme actuelle est un cas normal et attendu (ex. variable encore à sa valeur par défaut non-JSON), déjà explicitement documenté par le commentaire de la fonction ("Ne lève jamais... même convention que côté serveur"). Un `console.warn` ici bruiterait la console à chaque usage légitime. | Lot 4 "modernisation JS", 18/09/2026 |
|
||||
| `static/document/js/document-editor.js` (`FORGE_DOC_STYLE_PRESETS`, `FORGE_DOC_SHAPE_KINDS`, `FORGE_DOC_SNAP_SIZE` — 3 sites) | `eslint:no-var`, `eslint:vars-on-top` | Constantes de premier niveau déclarées en `var` plutôt que `const` : un `<script src>` de page est rejoué TEL QUEL par `pjax.js` (`runScriptsIn`) à chaque navigation interne — une redéclaration `let`/`const` au premier niveau lèverait `SyntaxError: already declared` à la 2e exécution (voir l'en-tête de `static/pjax.js`, et le commentaire d'en-tête de ce fichier). `var` est le seul mot-clé sûr à ce niveau ; tout le reste du fichier (état mutable, y compris à l'intérieur des fonctions) est bien en `let`/`const`, porté par `window.forgeDocState` plutôt que par des variables de module (même convention que `static/game/js/scenes/scene-editor.js` et les autres scripts de page existants, qui n'ont eux aucune constante de ce genre à déclarer). | Session du 20/09/2026 |
|
||||
| `templates/document/document_edit.html` (`.docPageThumbCard`, cartes de page dans la bande de vignettes) + `static/document/js/document-editor.js` (`forgeDocRenderPageTabs`) | `Web:S6819`, `Web:MouseEventWithoutKeyboardEquivalentCheck` (Sonar) | **`div role="button"` volontaire, pas une entorse à l'accessibilité — choix imposé par un vrai bug de parsing HTML trouvé et corrigé le 21/09/2026.** Chaque vignette embarque le HTML RÉEL de la page (`rendered_pages[page.id]` / `pageThumbsHtml[p.id]`), qui peut contenir de vrais `<button>` issus d'un mini-jeu (Suivant/Recommencer d'un quiz, association, memory, mots mêlés, scénario). Avec `.docPageThumbCard` en `<button>` (version initiale), le premier `<button>` imbriqué du contenu copié force le parseur HTML à refermer prématurément le bouton englobant (algorithme de parsing standard, identique navigateur réel/jsdom) — corrompant l'arbre DOM : constaté concrètement via un test jsdom contre une vraie page support avec un quiz (`.docPageThumbCard[data-page-id]` retournait 3 éléments pour 2 pages réelles, une carte dupliquée et détachée de `#docPageTabs`, retrouvée comme enfant direct de `<main class="content">`). Un `<button>` ne peut structurellement pas contenir un autre `<button>` — le HTML embarqué étant le rendu réel et non maîtrisable à l'avance (n'importe quel mini-jeu ajouté par le créateur), `div role="button" tabindex="0"` est la seule option HTML valide. L'équivalent clavier (Entrée/Espace déclenche `forgeDocSwitchPage`, même effet que le clic) est bien posé côté JS (`card.addEventListener('keydown', ...)`), donc le finding clavier de Sonar est un faux positif : l'analyseur statique ne voit pas les `addEventListener` attachés dynamiquement. Les vrais boutons copiés à l'intérieur de la vignette (`.docPageThumbInner`) portent l'attribut `inert` (en plus de `pointer-events:none` déjà en CSS) pour ne jamais devenir atteignables au clavier — vérifié par un test jsdom dédié. | Session du 21/09/2026 |
|
||||
| `templates/document/document_edit.html` (`.docPageListItem`, rangées de la section "Pages" du panneau gauche) + `static/document/js/document-editor.js` (`forgeDocRenderPageList`) | `Web:S6819`, `Web:MouseEventWithoutKeyboardEquivalentCheck` (Sonar) | `div role="button" tabindex="0"` volontaire : chaque rangée contient de vrais `<button>` d'action (monter/descendre/renommer/supprimer, voir `.docPageListActions`), qu'un `<button>` englobant ne pourrait pas contenir validement (imbrication de `<button>` invalide, le parseur HTML referme le bouton englobant trop tôt — même défaut structurel déjà rencontré et corrigé de la même façon ailleurs dans ce fichier). L'équivalent clavier (Entrée/Espace déclenche `forgeDocSwitchPage`, même effet que le clic) est posé côté JS (`item.addEventListener('keydown', ...)`), donc le finding clavier de Sonar est un faux positif : l'analyseur statique ne voit pas les `addEventListener` attachés dynamiquement. Vérifié par un test jsdom dédié (rôle `button`, équivalent clavier fonctionnel). | Session du 21/09/2026 |
|
||||
|
||||
### Détail — `javascript:S8786` (ReDoS), lot 1 "modernisation JS"
|
||||
|
||||
|
||||
@@ -4,17 +4,14 @@ document_engine — support de formation : entité racine séparée du jeu 2D
|
||||
document_engine/pages/, retour utilisateur du 21/09/2026 : "il faut
|
||||
implémenter un système de page") ; chaque page structure son propre
|
||||
contenu en deux couches sur le même canevas :
|
||||
- le flux de contenu (titre/paragraphe/image/bouton/mini-jeux), organisé
|
||||
en rangées ("row") par le moteur d'inférence de layout (voir
|
||||
document_engine/rendering/render_document_element.py) ;
|
||||
- la couche de formes libres (rectangle/cercle/triangle/trait), position-
|
||||
nées en absolu (x/y/width/height/rotation/z_index).
|
||||
le flux de contenu (titre/paragraphe/image/bouton/mini-jeux), organisé en
|
||||
rangées ("row") par le moteur d'inférence de layout (voir
|
||||
document_engine/rendering/render_document_element.py).
|
||||
|
||||
Tout est stocké dans support.db (voir db/supports/) : _document_pages (une
|
||||
ligne par page) et _document_elements (chaque élément appartient à
|
||||
exactement une page via page_id, les deux couches ci-dessus portées via
|
||||
parent_id — NULL = top-niveau ou forme libre, sinon = enfant d'une
|
||||
rangée).
|
||||
exactement une page via page_id, avec parent_id NULL au top-niveau ou
|
||||
enfant d'une rangée).
|
||||
|
||||
Aucun import croisé avec game_engine ou tout module lié au jeu 2D — voir
|
||||
le contrat import-linter dans pyproject.toml (game_engine | document_engine
|
||||
@@ -42,7 +39,6 @@ from .labels.element_kind_labels import (
|
||||
ELEMENT_KIND_LABELS,
|
||||
ELEMENT_LIBRARY,
|
||||
MINIGAME_KINDS,
|
||||
SHAPE_KINDS,
|
||||
element_default_attributes,
|
||||
)
|
||||
from .labels.memory_config import (
|
||||
@@ -103,7 +99,6 @@ __all__ = [
|
||||
"MIN_TIMER_SECONDS",
|
||||
"MIN_WORDS",
|
||||
"MINIGAME_KINDS",
|
||||
"SHAPE_KINDS",
|
||||
"add_document_element",
|
||||
"add_document_page",
|
||||
"delete_document_element",
|
||||
|
||||
@@ -11,7 +11,6 @@ from .mots_config import DEFAULT_MOTS_CONFIG
|
||||
from .quiz_config import DEFAULT_QUIZ_CONFIG
|
||||
from .scenario_config import DEFAULT_SCENARIO_CONFIG
|
||||
|
||||
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
|
||||
CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton")
|
||||
MINIGAME_KINDS = ("quiz", "association", "memory", "mots", "scenario", "zones")
|
||||
|
||||
@@ -19,17 +18,12 @@ MINIGAME_KINDS = ("quiz", "association", "memory", "mots", "scenario", "zones")
|
||||
# moteur de layout au dépôt d'un élément à côté d'un autre) — absent de
|
||||
# ELEMENT_LIBRARY, présent dans ELEMENT_KIND_LABELS pour l'affichage/debug.
|
||||
ELEMENT_LIBRARY: dict[str, dict[str, Any]] = {
|
||||
"mise_en_page": {"label": "Mise en page", "kinds": list(SHAPE_KINDS)},
|
||||
"contenu": {"label": "Contenu", "kinds": list(CONTENT_KINDS)},
|
||||
"minigames": {"label": "Mini-jeux", "kinds": list(MINIGAME_KINDS)},
|
||||
}
|
||||
|
||||
ELEMENT_KIND_LABELS: dict[str, str] = {
|
||||
"row": "Rangée",
|
||||
"rectangle": "Rectangle",
|
||||
"cercle": "Cercle",
|
||||
"triangle": "Triangle",
|
||||
"trait": "Trait",
|
||||
"titre": "Titre",
|
||||
"paragraphe": "Paragraphe",
|
||||
"image": "Image",
|
||||
@@ -42,19 +36,6 @@ ELEMENT_KIND_LABELS: dict[str, str] = {
|
||||
"zones": "Zones à risque",
|
||||
}
|
||||
|
||||
_SHAPE_DEFAULTS = {
|
||||
"x": 40,
|
||||
"y": 40,
|
||||
"width": 160,
|
||||
"height": 100,
|
||||
"rotation": 0,
|
||||
"z_index": 1,
|
||||
"fill": "#ff5f2e",
|
||||
"stroke": "#232a38",
|
||||
"stroke_width": 2,
|
||||
"label": "",
|
||||
}
|
||||
|
||||
_TEXT_DEFAULTS = {
|
||||
"bold": False,
|
||||
"italic": False,
|
||||
@@ -68,8 +49,6 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
|
||||
"""Attributs posés à la création d'un élément de ce type — cf.
|
||||
docs/plan/PLAN.md §3.3/§3.4/§3.5 pour la liste des propriétés
|
||||
éditables par panneau, ici juste leur valeur de départ."""
|
||||
if kind in SHAPE_KINDS:
|
||||
return dict(_SHAPE_DEFAULTS)
|
||||
if kind == "titre":
|
||||
return {"content": "Nouveau titre", "style": "titre1", **_TEXT_DEFAULTS}
|
||||
if kind == "paragraphe":
|
||||
|
||||
@@ -4,10 +4,6 @@ Catalogue statique des types d'éléments du support de formation : bibliothèqu
|
||||
groupée par catégorie (panneau gauche de l'éditeur), libellés d'affichage, et
|
||||
attributs par défaut posés à la création de chaque type.
|
||||
|
||||
## `SHAPE_KINDS: tuple[str, ...]`
|
||||
`("rectangle", "cercle", "triangle", "trait")` — couche de formes libres,
|
||||
toujours en position absolue (`parent_id = NULL`).
|
||||
|
||||
## `CONTENT_KINDS: tuple[str, ...]`
|
||||
`("titre", "paragraphe", "image", "bouton")` — éléments du flux, peuvent
|
||||
être top-niveau ou enfants d'une rangée.
|
||||
@@ -21,10 +17,9 @@ formulaires de contenu dédiés hors périmètre de cette passe.
|
||||
|
||||
## `ELEMENT_LIBRARY: dict[str, dict[str, Any]]`
|
||||
Bibliothèque affichée dans le panneau gauche, groupée par catégorie
|
||||
(`mise_en_page` / `contenu` / `minigames`), chaque entrée portant un
|
||||
`label` et sa liste de `kinds`. `"row"` n'y apparaît jamais — créé
|
||||
implicitement par le moteur de layout, jamais choisi directement dans la
|
||||
bibliothèque.
|
||||
(`contenu` / `minigames`), chaque entrée portant un `label` et sa liste
|
||||
de `kinds`. `"row"` n'y apparaît jamais — créé implicitement par le
|
||||
moteur de layout, jamais choisi directement dans la bibliothèque.
|
||||
|
||||
## `ELEMENT_KIND_LABELS: dict[str, str]`
|
||||
Libellé d'affichage pour chaque `kind`, y compris `"row"` (pour
|
||||
@@ -34,7 +29,6 @@ l'affichage/debug hors bibliothèque).
|
||||
Attributs posés à la création d'un élément de ce type (voir
|
||||
`document_engine/elements/add_document_element.py`).
|
||||
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` :
|
||||
formes (`x/y/width/height/rotation/z_index/fill/stroke/stroke_width/label`),
|
||||
texte (`content/style` + `bold/italic/underline/align/color`),
|
||||
image (`src/alt`), bouton (`label/target`), rangée (`gap/align/justify`),
|
||||
quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), association
|
||||
|
||||
@@ -3,8 +3,6 @@ import json
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
_SHAPE_TAGS = {"rectangle": "rect", "cercle": "circle", "trait": "line"}
|
||||
|
||||
|
||||
def render_document(elements: list[dict[str, Any]]) -> str:
|
||||
"""Assemble le document ENTIER à partir de la liste à plat renvoyée
|
||||
@@ -39,40 +37,6 @@ def _render_row(el: dict[str, Any], children_by_parent: dict[int | None, list[di
|
||||
return f'<div class="docRow" data-element-id="{el["id"]}" data-kind="row" style="{style}">{inner}</div>'
|
||||
|
||||
|
||||
def _render_shape(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||
a = el["attributes"]
|
||||
x, y, width, height = a["x"], a["y"], a["width"], a["height"]
|
||||
rotation, z_index = a.get("rotation", 0), a.get("z_index", 1)
|
||||
fill = html_lib.escape(str(a.get("fill", "#ff5f2e")))
|
||||
stroke = html_lib.escape(str(a.get("stroke", "#232a38")))
|
||||
stroke_width = a.get("stroke_width", 2)
|
||||
label = html_lib.escape(str(a.get("label", "")))
|
||||
wrap_style = (
|
||||
f"position:absolute; left:{x}px; top:{y}px; width:{width}px; height:{height}px; "
|
||||
f"transform:rotate({rotation}deg); z-index:{z_index};"
|
||||
)
|
||||
kind = el["kind"]
|
||||
stroke_attrs = f'stroke="{stroke}" stroke-width="{stroke_width}"'
|
||||
if kind == "triangle":
|
||||
points = f"{width / 2},0 {width},{height} 0,{height}"
|
||||
shape_svg = f'<polygon points="{points}" fill="{fill}" {stroke_attrs}></polygon>'
|
||||
else:
|
||||
tag = _SHAPE_TAGS.get(kind, "rect")
|
||||
if tag == "circle":
|
||||
cx, cy, r = width / 2, height / 2, min(width, height) / 2
|
||||
shape_svg = f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="{fill}" {stroke_attrs}></circle>'
|
||||
elif tag == "line":
|
||||
shape_svg = f'<line x1="0" y1="{height / 2}" x2="{width}" y2="{height / 2}" {stroke_attrs}></line>'
|
||||
else:
|
||||
shape_svg = f'<rect width="{width}" height="{height}" fill="{fill}" {stroke_attrs}></rect>'
|
||||
label_attr = f' aria-label="{label}"' if label else ' aria-hidden="true"'
|
||||
return (
|
||||
f'<div class="docShape" data-element-id="{el["id"]}" data-kind="{kind}" style="{wrap_style}"{label_attr}>'
|
||||
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}">{shape_svg}</svg>'
|
||||
f"</div>"
|
||||
)
|
||||
|
||||
|
||||
_STYLE_PRESETS = {
|
||||
"titre1": ("clamp(1.6rem,4vw,2rem)", 800, 1.15),
|
||||
"titre2": ("1.3rem", 800, 1.25),
|
||||
@@ -530,10 +494,6 @@ def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, li
|
||||
|
||||
_RENDERERS = {
|
||||
"row": _render_row,
|
||||
"rectangle": _render_shape,
|
||||
"cercle": _render_shape,
|
||||
"triangle": _render_shape,
|
||||
"trait": _render_shape,
|
||||
"titre": _render_text,
|
||||
"paragraphe": _render_text,
|
||||
"image": _render_image,
|
||||
|
||||
@@ -28,9 +28,6 @@ regroupement à chaque appel.
|
||||
- **Rangée** (`row`) : conteneur flex (`gap`/`align-items`/
|
||||
`justify-content` réels depuis `attributes`), enfants rendus
|
||||
récursivement.
|
||||
- **Formes** (`rectangle`/`cercle`/`triangle`/`trait`) : `<div>` positionné
|
||||
en absolu (`x`/`y`/`width`/`height`/`rotation`/`z_index` réels) contenant
|
||||
un SVG (`rect`/`circle`/`polygon`/`line` selon le type).
|
||||
- **Texte** (`titre`/`paragraphe`) : `<div>` stylé selon `style` (préréglage
|
||||
taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`.
|
||||
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide.
|
||||
|
||||
@@ -21,15 +21,12 @@ def document_edit(slug: str) -> str:
|
||||
PREMIÈRE page (triée par order_index) — changer de page se fait
|
||||
ensuite entièrement côté client via /document/<slug>/render?page_id=
|
||||
(voir static/document/js/document-editor.js), jamais un rechargement
|
||||
complet de cette route.
|
||||
|
||||
`rendered_pages` (retour utilisateur du 21/09/2026 : "je préfère
|
||||
avoir des miniatures") rend le document de CHAQUE page — pas
|
||||
seulement la page active — pour construire les vraies vignettes
|
||||
miniatures de la bande au-dessus du canevas (voir templates/document/
|
||||
document_edit.html, .docPageThumbInner) : un rendu réduit (CSS
|
||||
`transform: scale()`) du contenu RÉEL de chaque page, pas une carte
|
||||
générique numéro+titre.
|
||||
complet de cette route. La navigation entre pages (ajout/renommage/
|
||||
suppression/réordonnancement) vit dans une section dédiée du panneau
|
||||
gauche (retour utilisateur du 21/09/2026 : "une section qui s'ajoute
|
||||
dans le panneau de gauche pour ajouter une page et naviguer entre
|
||||
elles"), qui n'a besoin que des métadonnées de page (`pages`), jamais
|
||||
d'un rendu de leur contenu.
|
||||
|
||||
Les attributs de chaque élément sont revalidés (document_engine.
|
||||
sanitize_element_attributes) avant d'atteindre le client — jamais les
|
||||
@@ -42,16 +39,10 @@ def document_edit(slug: str) -> str:
|
||||
pages = document_engine.list_document_pages(slug)
|
||||
active_page = pages[0]
|
||||
|
||||
rendered_pages: dict[int, str] = {}
|
||||
active_elements: list[dict[str, Any]] = []
|
||||
for page in pages:
|
||||
page_elements = [
|
||||
{**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])}
|
||||
for el in document_engine.list_document_elements(slug, page["id"])
|
||||
]
|
||||
rendered_pages[page["id"]] = document_engine.render_document(page_elements)
|
||||
if page["id"] == active_page["id"]:
|
||||
active_elements = page_elements
|
||||
active_elements: list[dict[str, Any]] = [
|
||||
{**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])}
|
||||
for el in document_engine.list_document_elements(slug, active_page["id"])
|
||||
]
|
||||
|
||||
return render_template(
|
||||
"document/document_edit.html",
|
||||
@@ -59,8 +50,7 @@ def document_edit(slug: str) -> str:
|
||||
pages=pages,
|
||||
active_page=active_page,
|
||||
elements=active_elements,
|
||||
rendered_document=rendered_pages[active_page["id"]],
|
||||
rendered_pages=rendered_pages,
|
||||
rendered_document=document_engine.render_document(active_elements),
|
||||
element_library=document_engine.ELEMENT_LIBRARY,
|
||||
element_kind_labels=document_engine.ELEMENT_KIND_LABELS,
|
||||
)
|
||||
|
||||
@@ -260,12 +260,6 @@ body.objectEditBody > main.content {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.docShapesGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.docLibItem {
|
||||
border: 1px solid var(--doc-border);
|
||||
border-radius: 10px;
|
||||
@@ -279,14 +273,6 @@ body.objectEditBody > main.content {
|
||||
box-shadow: var(--doc-glow);
|
||||
}
|
||||
|
||||
.docLibItem--shape {
|
||||
padding: 10px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.docLibItem--row {
|
||||
padding: 9px 10px;
|
||||
display: flex;
|
||||
@@ -315,10 +301,6 @@ body.objectEditBody > main.content {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.docLibIcon--rectangle::before { content: "▭"; }
|
||||
.docLibIcon--cercle::before { content: "○"; }
|
||||
.docLibIcon--triangle::before { content: "△"; }
|
||||
.docLibIcon--trait::before { content: "―"; }
|
||||
.docLibIcon--paragraphe::before { content: "¶"; }
|
||||
.docLibIcon--image::before { content: "🖼"; }
|
||||
.docLibIcon--bouton::before { content: "⬚"; }
|
||||
@@ -387,8 +369,7 @@ body.objectEditBody > main.content {
|
||||
|
||||
/* Enfant qui reçoit le HTML rendu (voir #docPage vs #docPageContent dans
|
||||
document-editor.js — le rafraîchissement du canevas ne remplace QUE ce
|
||||
contenu, jamais .docPage elle-même, pour que .docSnapGridOverlay,
|
||||
sa voisine, survive à chaque rafraîchissement). */
|
||||
contenu, jamais .docPage elle-même). */
|
||||
.docPageContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -447,17 +428,6 @@ body.objectEditBody > main.content {
|
||||
box-shadow: inset -3px 0 0 0 var(--doc-accent);
|
||||
}
|
||||
|
||||
.docShape[data-element-id] {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.docShape[data-element-id].docSelected {
|
||||
outline: 2px solid var(--doc-accent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.docText {
|
||||
outline: none;
|
||||
}
|
||||
@@ -1096,27 +1066,6 @@ img.docImage {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* ---- grille d'accroche (formes libres) — voir docs/plan/PLAN.md,
|
||||
"grille d'accroche réelle" : visible en overlay pendant le déplacement
|
||||
d'une forme (voir .docPage--dragging-shape ci-dessous), jamais
|
||||
décorative. */
|
||||
.docSnapGridOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease;
|
||||
background-image:
|
||||
linear-gradient(to right, rgb(255 95 46 / 12%) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgb(255 95 46 / 12%) 1px, transparent 1px);
|
||||
background-size: var(--doc-snap-size, 8px) var(--doc-snap-size, 8px);
|
||||
}
|
||||
|
||||
.docPage.docPage--dragging-shape .docSnapGridOverlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.docZoomPill {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
@@ -1213,125 +1162,53 @@ img.docImage {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Bande de VIGNETTES MINIATURES des pages, au-dessus de la page
|
||||
ET centrée dans le conteneur du milieu (.docCanvasArea, jamais toute
|
||||
la largeur de l'éditeur par-dessus les panneaux latéraux — retour
|
||||
utilisateur du 21/09/2026 : "je préfère avoir des miniatures ...
|
||||
centré dans le conteneur du milieu"). Chaque vignette affiche un
|
||||
VRAI rendu réduit du contenu de sa page (voir .docPageThumbInner plus
|
||||
bas, et rendered_pages côté serveur dans routes/document/
|
||||
document_edit.py) — jamais une carte générique numéro/titre. Masquée
|
||||
en Mode Aperçu (voir la règle .docEditor3--preview plus bas), qui a
|
||||
sa propre navigation via .docPageNavPill ci-dessus. ---- */
|
||||
.docPageThumbs {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 4px 20px 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.docPageThumbCard {
|
||||
/* ---- Section "Pages" du panneau GAUCHE (retour utilisateur du
|
||||
21/09/2026 : "une section qui s'ajoute dans le panneau de gauche pour
|
||||
ajouter une page et naviguer entre elles", à la place de l'ex-
|
||||
catégorie "Mise en page"/formes libres, supprimée) — une simple liste
|
||||
(pas de vignette visuelle), toujours visible en tête du panneau. ---- */
|
||||
.docPageSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.docPageThumbCard.is-dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.docPageThumbCard:focus-visible {
|
||||
outline: 2px solid var(--doc-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.docPageThumbFrame {
|
||||
/* <span> (jamais <div>, invalide comme enfant direct d'un <button> —
|
||||
voir templates/document/document_edit.html) : display:block requis
|
||||
pour que width/aspect-ratio s'appliquent normalement. */
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 132px;
|
||||
aspect-ratio: 4 / 3;
|
||||
border: 2px solid var(--doc-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--doc-card);
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
|
||||
transition: border-color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.docPageThumbCard:hover .docPageThumbFrame {
|
||||
border-color: var(--doc-accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.docPageThumbCard.is-active .docPageThumbFrame {
|
||||
border-color: var(--doc-accent);
|
||||
box-shadow: 0 0 0 2px var(--doc-accent);
|
||||
}
|
||||
|
||||
/* Astuce "scale inverse" pour une vraie vignette : le contenu est rendu
|
||||
à sa largeur RÉELLE (voir .docPage, 760px) puis réduit visuellement —
|
||||
jamais un second moteur de rendu séparé pour les miniatures, c'est le
|
||||
MÊME rendu que le canevas principal, juste redimensionné en CSS. Le
|
||||
cadre (.docPageThumbFrame) le rogne verticalement s'il dépasse — un
|
||||
support de formation "page" est proche d'un format diapositive, ce
|
||||
recadrage reste représentatif. */
|
||||
.docPageThumbInner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 760px;
|
||||
transform: scale(0.1737);
|
||||
transform-origin: top left;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.docPageThumbDelete {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgb(11 14 20 / 65%);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.docPageThumbCard:hover .docPageThumbDelete {
|
||||
.docPageList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.docPageThumbDelete:hover {
|
||||
background: var(--doc-quiz-danger-border);
|
||||
.docPageListItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid var(--doc-border);
|
||||
border-radius: 8px;
|
||||
background: var(--doc-card);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.docPageThumbs.docPageTabs--single .docPageThumbDelete {
|
||||
display: none;
|
||||
.docPageListItem:hover {
|
||||
border-color: var(--doc-accent);
|
||||
}
|
||||
|
||||
.docPageThumbLabel {
|
||||
max-width: 132px;
|
||||
font-size: 12px;
|
||||
.docPageListItem.is-active {
|
||||
border-color: var(--doc-accent);
|
||||
box-shadow: 0 0 0 1px var(--doc-accent);
|
||||
}
|
||||
|
||||
.docPageListItem:focus-visible {
|
||||
outline: 2px solid var(--doc-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.docPageListTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--doc-muted);
|
||||
overflow: hidden;
|
||||
@@ -1339,11 +1216,11 @@ img.docImage {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.docPageThumbCard.is-active .docPageThumbLabel {
|
||||
.docPageListItem.is-active .docPageListTitle {
|
||||
color: var(--doc-text);
|
||||
}
|
||||
|
||||
.docPageThumbLabel[contenteditable="true"] {
|
||||
.docPageListTitle[contenteditable="true"] {
|
||||
outline: none;
|
||||
border-bottom: 1px solid var(--doc-accent);
|
||||
cursor: text;
|
||||
@@ -1351,21 +1228,41 @@ img.docImage {
|
||||
color: var(--doc-text);
|
||||
}
|
||||
|
||||
.docPageThumbAddCard .docPageThumbFrame {
|
||||
.docPageListActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.docPageListActions button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--doc-muted);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-style: dashed;
|
||||
color: var(--doc-muted);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.docPageThumbAddCard:hover .docPageThumbFrame {
|
||||
.docPageListActions button:hover:not(:disabled) {
|
||||
background: var(--doc-bg-2);
|
||||
color: var(--doc-text);
|
||||
}
|
||||
|
||||
.docPageListActions button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.docPageListDelete:hover:not(:disabled) {
|
||||
color: var(--doc-quiz-danger-border);
|
||||
}
|
||||
|
||||
/* ---- RIGHT SIDEBAR : Propriétés ---- */
|
||||
.docField {
|
||||
display: flex;
|
||||
@@ -1606,8 +1503,7 @@ img.docImage {
|
||||
.docEditor3.docEditor3--preview .docSidebarLeft,
|
||||
.docEditor3.docEditor3--preview .docSidebarRight,
|
||||
.docEditor3.docEditor3--preview .docBottomNav,
|
||||
.docEditor3.docEditor3--preview .docZoomPill,
|
||||
.docEditor3.docEditor3--preview .docPageThumbs {
|
||||
.docEditor3.docEditor3--preview .docZoomPill {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,14 +19,6 @@ var FORGE_DOC_STYLE_PRESETS = {
|
||||
legende: 'Légende',
|
||||
};
|
||||
|
||||
// Même raison que FORGE_DOC_STYLE_PRESETS ci-dessus.
|
||||
// eslint-disable-next-line no-var, vars-on-top
|
||||
var FORGE_DOC_SHAPE_KINDS = ['rectangle', 'cercle', 'triangle', 'trait'];
|
||||
|
||||
function forgeDocIsShapeKind(kind) {
|
||||
return FORGE_DOC_SHAPE_KINDS.includes(kind);
|
||||
}
|
||||
|
||||
function forgeDocChildrenOf(parentId) {
|
||||
return window.forgeDocState.elements
|
||||
.filter((el) => el.parent_id === parentId)
|
||||
@@ -116,15 +108,6 @@ function forgeDocRefreshCanvas() {
|
||||
window.forgeDocState.selectedId = null;
|
||||
forgeDocRenderProps(null);
|
||||
}
|
||||
// La vignette miniature de la page ACTIVE (voir .docPageThumbInner,
|
||||
// static/document/document-editor.css) reflète le MÊME rendu que le
|
||||
// canevas principal — seule cette page peut avoir changé tant qu'on
|
||||
// ne l'a pas quittée (voir forgeDocSwitchPage), jamais besoin de
|
||||
// reconstruire toute la bande de vignettes ici.
|
||||
const { activePageId } = window.forgeDocState;
|
||||
window.forgeDocState.pageThumbsHtml[activePageId] = data.html;
|
||||
const thumbInner = document.querySelector(`.docPageThumbInner[data-page-id="${activePageId}"]`);
|
||||
if (thumbInner) thumbInner.innerHTML = data.html;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -236,8 +219,7 @@ function forgeDocUpdateAttributes(id, newAttributes) {
|
||||
* ------------------------------------------------------------------- */
|
||||
|
||||
function forgeDocFindDropTarget(clientX, clientY) {
|
||||
const candidates = Array.from(document.querySelectorAll('#docPage [data-element-id]'))
|
||||
.filter((dom) => !forgeDocIsShapeKind(dom.dataset.kind));
|
||||
const candidates = Array.from(document.querySelectorAll('#docPage [data-element-id]'));
|
||||
let best = null;
|
||||
let bestDist = Infinity;
|
||||
candidates.forEach((dom) => {
|
||||
@@ -421,10 +403,13 @@ function forgeDocBindCanvasDropZone() {
|
||||
/* ---------------------------------------------------------------------
|
||||
* Pages — un support est composé de plusieurs pages (retour utilisateur
|
||||
* du 21/09/2026 : "il faut implémenter un système de page"), chacune un
|
||||
* document indépendant affiché SEUL sur le canevas à la fois. Bande
|
||||
* d'ONGLETS au-dessus du canevas pour créer/renommer (double-clic)/
|
||||
* réordonner (glisser)/supprimer (voir templates/document/
|
||||
* document_edit.html) — jamais un panneau latéral (retour utilisateur).
|
||||
* document indépendant affiché SEUL sur le canevas à la fois. Section
|
||||
* dédiée dans le panneau GAUCHE (retour utilisateur du 21/09/2026 :
|
||||
* "une section qui s'ajoute dans le panneau de gauche pour ajouter une
|
||||
* page et naviguer entre elles", à la place de l'ex-catégorie "Mise en
|
||||
* page"/formes libres, supprimée) — une simple liste, jamais de vignette
|
||||
* visuelle (voir templates/document/document_edit.html, .docPageList) :
|
||||
* créer/renommer (bouton crayon)/réordonner (boutons haut/bas)/supprimer.
|
||||
* Changer de page VIDE la pile Annuler/Rétablir (voir forgeDocSwitchPage)
|
||||
* : une commande empilée sur une autre page rejouerait un appel AJAX
|
||||
* ciblant des éléments qui ne sont plus affichés, aucun sens à la garder.
|
||||
@@ -484,7 +469,7 @@ function forgeDocSwitchPage(pageId) {
|
||||
forgeDocUpdateHistoryButtons();
|
||||
window.forgeDocState.selectedId = null;
|
||||
forgeDocRenderProps(null);
|
||||
forgeDocRenderPageTabs();
|
||||
forgeDocRenderPageList();
|
||||
forgeDocUpdatePageNavPill();
|
||||
return forgeDocRefreshCanvas();
|
||||
}
|
||||
@@ -494,11 +479,6 @@ function forgeDocAddPage() {
|
||||
window.forgeDocState.pages.push({
|
||||
id: result.id, title: result.title, order_index: window.forgeDocState.pages.length,
|
||||
});
|
||||
// Vignette vide (aucun élément sur une page neuve) — forgeDocRefreshCanvas
|
||||
// la remplacera de toute façon juste après via forgeDocSwitchPage, posé
|
||||
// ici explicitement plutôt que de compter sur le repli `|| ''` de
|
||||
// forgeDocRenderPageTabs.
|
||||
window.forgeDocState.pageThumbsHtml[result.id] = '';
|
||||
return forgeDocSwitchPage(result.id);
|
||||
});
|
||||
}
|
||||
@@ -507,7 +487,7 @@ function forgeDocRenamePage(pageId, title) {
|
||||
return forgeDocApiPageRename(pageId, title).then((result) => {
|
||||
const page = window.forgeDocState.pages.find((p) => p.id === pageId);
|
||||
if (page) page.title = result.title;
|
||||
forgeDocRenderPageTabs();
|
||||
forgeDocRenderPageList();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -522,15 +502,31 @@ function forgeDocDeletePage(pageId) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
window.forgeDocState.pages = window.forgeDocState.pages.filter((p) => p.id !== pageId);
|
||||
delete window.forgeDocState.pageThumbsHtml[pageId];
|
||||
if (window.forgeDocState.activePageId === pageId) {
|
||||
return forgeDocSwitchPage(forgeDocOrderedPages()[0].id);
|
||||
}
|
||||
forgeDocRenderPageTabs();
|
||||
forgeDocRenderPageList();
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocMovePage(pageId, direction) {
|
||||
const ordered = forgeDocOrderedPages();
|
||||
const index = ordered.findIndex((p) => p.id === pageId);
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (newIndex < 0 || newIndex >= ordered.length) return;
|
||||
forgeDocApiPageMove(pageId, newIndex).then(() => {
|
||||
const moved = window.forgeDocState.pages.find((p) => p.id === pageId);
|
||||
const rest = window.forgeDocState.pages
|
||||
.filter((p) => p.id !== pageId)
|
||||
.sort((a, b) => a.order_index - b.order_index);
|
||||
rest.splice(newIndex, 0, moved);
|
||||
rest.forEach((p, i) => { p.order_index = i; });
|
||||
window.forgeDocState.pages = rest;
|
||||
forgeDocRenderPageList();
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocSelectAllText(el) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
@@ -539,9 +535,10 @@ function forgeDocSelectAllText(el) {
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
function forgeDocStartRenamingPageTab(titleEl) {
|
||||
const tab = titleEl.closest('.docPageThumbCard');
|
||||
const pageId = Number(tab.dataset.pageId);
|
||||
function forgeDocStartRenamingPage(pageId) {
|
||||
const item = document.querySelector(`.docPageListItem[data-page-id="${pageId}"]`);
|
||||
if (!item) return;
|
||||
const titleEl = item.querySelector('.docPageListTitle');
|
||||
const original = titleEl.textContent;
|
||||
titleEl.setAttribute('contenteditable', 'true');
|
||||
titleEl.focus();
|
||||
@@ -578,107 +575,60 @@ function forgeDocStartRenamingPageTab(titleEl) {
|
||||
titleEl.addEventListener('keydown', onKeydown);
|
||||
}
|
||||
|
||||
function forgeDocRenderPageTabs() {
|
||||
const container = document.getElementById('docPageTabs');
|
||||
function forgeDocRenderPageList() {
|
||||
const container = document.getElementById('docPageList');
|
||||
if (!container) return;
|
||||
const pages = forgeDocOrderedPages();
|
||||
container.classList.toggle('docPageTabs--single', pages.length <= 1);
|
||||
// Le contenu de chaque vignette (.docPageThumbInner) vient du CACHE
|
||||
// client (window.forgeDocState.pageThumbsHtml, alimenté au chargement
|
||||
// et tenu à jour par forgeDocRefreshCanvas) — jamais ré-échappé ici :
|
||||
// c'est déjà du HTML de confiance produit par document_engine.
|
||||
// render_document côté serveur (même contrat que data.html pour le
|
||||
// canevas principal), pas du texte libre du créateur.
|
||||
// div + role="button" ici, jamais <button> : le contenu de la vignette
|
||||
// (pageThumbsHtml) est le HTML réel de la page, qui peut contenir de
|
||||
// vrais <button> (Suivant/Recommencer d'un mini-jeu) — un <button> dans
|
||||
// un <button> est invalide et le parseur HTML referme le bouton
|
||||
// englobant trop tôt, corrompant l'arbre DOM. L'équivalent clavier
|
||||
// (Entrée/Espace) est posé plus bas ; inert neutralise les vrais
|
||||
// boutons copiés dans la vignette (jamais atteignables au clavier).
|
||||
const cardsHtml = pages.map((p) => `
|
||||
<div class="docPageThumbCard ${p.id === window.forgeDocState.activePageId ? 'is-active' : ''}"
|
||||
// div + role="button" ici, jamais <button> : la rangée contient de
|
||||
// vrais <button> d'action (monter/descendre/renommer/supprimer), qu'un
|
||||
// <button> englobant ne pourrait pas contenir validement. L'équivalent
|
||||
// clavier (Entrée/Espace) est posé plus bas.
|
||||
container.innerHTML = pages.map((p, index) => `
|
||||
<div class="docPageListItem ${p.id === window.forgeDocState.activePageId ? 'is-active' : ''}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-page-id="${p.id}"
|
||||
draggable="true">
|
||||
<span class="docPageThumbFrame">
|
||||
<span class="docPageThumbInner" data-page-id="${p.id}" inert>${window.forgeDocState.pageThumbsHtml[p.id] || ''}</span>
|
||||
<span class="docPageThumbDelete" data-page-id="${p.id}" title="Supprimer cette page">✕</span>
|
||||
</span>
|
||||
<span class="docPageThumbLabel">${forgeDocEscapeHtml(p.title)}</span>
|
||||
data-page-id="${p.id}">
|
||||
<span class="docPageListTitle">${forgeDocEscapeHtml(p.title)}</span>
|
||||
<div class="docPageListActions">
|
||||
<button type="button" class="docPageListMove" data-dir="up" data-page-id="${p.id}" aria-label="Déplacer vers le haut" title="Déplacer vers le haut"${index === 0 ? ' disabled' : ''}>↑</button>
|
||||
<button type="button" class="docPageListMove" data-dir="down" data-page-id="${p.id}" aria-label="Déplacer vers le bas" title="Déplacer vers le bas"${index === pages.length - 1 ? ' disabled' : ''}>↓</button>
|
||||
<button type="button" class="docPageListRename" data-page-id="${p.id}" aria-label="Renommer cette page" title="Renommer cette page">✎</button>
|
||||
<button type="button" class="docPageListDelete" data-page-id="${p.id}" aria-label="Supprimer cette page" title="Supprimer cette page"${pages.length <= 1 ? ' disabled' : ''}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
container.innerHTML = `${cardsHtml}<button type="button" class="docPageThumbCard docPageThumbAddCard" id="docPageTabAdd" aria-label="Ajouter une page" title="Ajouter une page"><span class="docPageThumbFrame docPageThumbAddFrame">+</span></button>`;
|
||||
|
||||
container.querySelectorAll('.docPageThumbCard[data-page-id]').forEach((card) => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('docPageThumbDelete')) return;
|
||||
forgeDocSwitchPage(Number(card.dataset.pageId));
|
||||
container.querySelectorAll('.docPageListItem[data-page-id]').forEach((item) => {
|
||||
item.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.docPageListActions')) return;
|
||||
forgeDocSwitchPage(Number(item.dataset.pageId));
|
||||
});
|
||||
card.addEventListener('keydown', (e) => {
|
||||
item.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
forgeDocSwitchPage(Number(card.dataset.pageId));
|
||||
forgeDocSwitchPage(Number(item.dataset.pageId));
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('.docPageThumbLabel').forEach((labelEl) => {
|
||||
labelEl.addEventListener('dblclick', (e) => {
|
||||
container.querySelectorAll('.docPageListMove').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
forgeDocStartRenamingPageTab(labelEl);
|
||||
forgeDocMovePage(Number(btn.dataset.pageId), btn.dataset.dir);
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('.docPageThumbDelete').forEach((btn) => {
|
||||
container.querySelectorAll('.docPageListRename').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
forgeDocStartRenamingPage(Number(btn.dataset.pageId));
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('.docPageListDelete').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
forgeDocDeletePage(Number(btn.dataset.pageId));
|
||||
});
|
||||
});
|
||||
document.getElementById('docPageTabAdd').addEventListener('click', () => forgeDocAddPage());
|
||||
}
|
||||
|
||||
function forgeDocBindPageTabsDrag() {
|
||||
// Délégation sur le CONTENEUR (jamais les vignettes elles-mêmes,
|
||||
// recréées à chaque forgeDocRenderPageTabs) : un seul jeu de listeners
|
||||
// pour toute la session, même principe que forgeDocMotsBindGlobalMouseup.
|
||||
const container = document.getElementById('docPageTabs');
|
||||
let draggedId = null;
|
||||
|
||||
container.addEventListener('dragstart', (e) => {
|
||||
const card = e.target.closest('.docPageThumbCard');
|
||||
if (!card || !card.dataset.pageId) return;
|
||||
draggedId = Number(card.dataset.pageId);
|
||||
card.classList.add('is-dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
container.addEventListener('dragend', (e) => {
|
||||
const card = e.target.closest('.docPageThumbCard');
|
||||
if (card) card.classList.remove('is-dragging');
|
||||
});
|
||||
container.addEventListener('dragover', (e) => {
|
||||
if (draggedId !== null) e.preventDefault();
|
||||
});
|
||||
container.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
if (draggedId === null) return;
|
||||
const targetCard = e.target.closest('.docPageThumbCard[data-page-id]');
|
||||
const ordered = forgeDocOrderedPages();
|
||||
const newIndex = targetCard
|
||||
? ordered.findIndex((p) => p.id === Number(targetCard.dataset.pageId))
|
||||
: ordered.length - 1;
|
||||
const movedId = draggedId;
|
||||
draggedId = null;
|
||||
forgeDocApiPageMove(movedId, newIndex).then(() => {
|
||||
const moved = window.forgeDocState.pages.find((p) => p.id === movedId);
|
||||
const rest = window.forgeDocState.pages
|
||||
.filter((p) => p.id !== movedId)
|
||||
.sort((a, b) => a.order_index - b.order_index);
|
||||
rest.splice(newIndex, 0, moved);
|
||||
rest.forEach((p, i) => { p.order_index = i; });
|
||||
window.forgeDocState.pages = rest;
|
||||
forgeDocRenderPageTabs();
|
||||
});
|
||||
});
|
||||
const addBtn = document.getElementById('docPageListAdd');
|
||||
if (addBtn) addBtn.onclick = () => forgeDocAddPage();
|
||||
}
|
||||
|
||||
function forgeDocBindPageNavPill() {
|
||||
@@ -694,59 +644,6 @@ function forgeDocBindPageNavPill() {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
* Formes libres — position absolue, glissées à la souris (pas de HTML5
|
||||
* DnD, pas de logique de rangée) avec grille d'accroche réelle.
|
||||
* ------------------------------------------------------------------- */
|
||||
|
||||
// Même raison que FORGE_DOC_STYLE_PRESETS en tête de fichier.
|
||||
// eslint-disable-next-line no-var, vars-on-top
|
||||
var FORGE_DOC_SNAP_SIZE = 8;
|
||||
|
||||
function forgeDocBindShapeDrag(dom, elementId) {
|
||||
dom.addEventListener('mousedown', (e) => {
|
||||
if (window.forgeDocState.previewMode) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
forgeDocSelectElement(elementId);
|
||||
const el = window.forgeDocState.elementsById[elementId];
|
||||
window.forgeDocState.shapeDrag = {
|
||||
id: elementId,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origX: el.attributes.x,
|
||||
origY: el.attributes.y,
|
||||
};
|
||||
document.getElementById('docPage').classList.add('docPage--dragging-shape');
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocBindShapeDragGlobalHandlers() {
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
const drag = window.forgeDocState.shapeDrag;
|
||||
if (!drag) return;
|
||||
const zoomFactor = window.forgeDocState.zoom / 100;
|
||||
const dx = (e.clientX - drag.startX) / zoomFactor;
|
||||
const dy = (e.clientY - drag.startY) / zoomFactor;
|
||||
const newX = Math.round((drag.origX + dx) / FORGE_DOC_SNAP_SIZE) * FORGE_DOC_SNAP_SIZE;
|
||||
const newY = Math.round((drag.origY + dy) / FORGE_DOC_SNAP_SIZE) * FORGE_DOC_SNAP_SIZE;
|
||||
const dom = document.querySelector(`[data-element-id="${drag.id}"]`);
|
||||
if (dom) { dom.style.left = `${newX}px`; dom.style.top = `${newY}px`; }
|
||||
drag.lastX = newX;
|
||||
drag.lastY = newY;
|
||||
});
|
||||
document.addEventListener('mouseup', () => {
|
||||
const drag = window.forgeDocState.shapeDrag;
|
||||
if (!drag) return;
|
||||
window.forgeDocState.shapeDrag = null;
|
||||
document.getElementById('docPage').classList.remove('docPage--dragging-shape');
|
||||
if (typeof drag.lastX === 'number' && (drag.lastX !== drag.origX || drag.lastY !== drag.origY)) {
|
||||
const el = window.forgeDocState.elementsById[drag.id];
|
||||
forgeDocUpdateAttributes(drag.id, { ...el.attributes, x: drag.lastX, y: drag.lastY });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
* Sélection & panneau Propriétés.
|
||||
* ------------------------------------------------------------------- */
|
||||
@@ -758,15 +655,11 @@ function forgeDocBindCanvasInteractions() {
|
||||
e.stopPropagation();
|
||||
forgeDocSelectElement(elementId);
|
||||
});
|
||||
if (forgeDocIsShapeKind(dom.dataset.kind)) {
|
||||
forgeDocBindShapeDrag(dom, elementId);
|
||||
} else {
|
||||
dom.setAttribute('draggable', 'true');
|
||||
dom.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/forge-doc-move', String(elementId));
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
}
|
||||
dom.setAttribute('draggable', 'true');
|
||||
dom.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/forge-doc-move', String(elementId));
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
});
|
||||
forgeDocInitQuizPlayers();
|
||||
forgeDocInitAssociationPlayers();
|
||||
@@ -1625,37 +1518,6 @@ function forgeDocRenderTextProps(panel, el) {
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocRenderShapeProps(panel, el) {
|
||||
const a = el.attributes;
|
||||
panel.innerHTML = `
|
||||
${forgeDocPropsHeader('forme')}
|
||||
<div class="docField"><label for="docShapeLabel">Libellé</label><input type="text" id="docShapeLabel" value="${a.label || ''}"></div>
|
||||
<div class="docFieldRow"><label for="docShapeX">Position X</label><input type="number" id="docShapeX" value="${a.x}" style="width:90px;"></div>
|
||||
<div class="docFieldRow"><label for="docShapeY">Position Y</label><input type="number" id="docShapeY" value="${a.y}" style="width:90px;"></div>
|
||||
<div class="docFieldRow"><label for="docShapeWidth">Largeur</label><input type="number" id="docShapeWidth" value="${a.width}" style="width:90px;"></div>
|
||||
<div class="docFieldRow"><label for="docShapeHeight">Hauteur</label><input type="number" id="docShapeHeight" value="${a.height}" style="width:90px;"></div>
|
||||
<div class="docFieldRow"><label for="docShapeRotation">Rotation (°)</label><input type="number" id="docShapeRotation" value="${a.rotation}" style="width:90px;"></div>
|
||||
<div class="docFieldRow"><label for="docShapeZ">Ordre (z-index)</label><input type="number" id="docShapeZ" value="${a.z_index}" style="width:90px;"></div>
|
||||
<div class="docFieldRow"><label for="docShapeFill">Couleur de remplissage</label><input type="color" id="docShapeFill" value="${a.fill}"></div>
|
||||
<div class="docFieldRow"><label for="docShapeStroke">Couleur de bordure</label><input type="color" id="docShapeStroke" value="${a.stroke}"></div>
|
||||
<div class="docFieldRow"><label for="docShapeStrokeWidth">Épaisseur de bordure</label><input type="number" id="docShapeStrokeWidth" value="${a.stroke_width}" style="width:90px;"></div>
|
||||
${forgeDocDeleteButtonHtml()}
|
||||
`;
|
||||
|
||||
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
|
||||
|
||||
document.getElementById('docShapeLabel').addEventListener('change', (e) => patch({ label: e.target.value }));
|
||||
document.getElementById('docShapeX').addEventListener('change', (e) => patch({ x: Number(e.target.value) }));
|
||||
document.getElementById('docShapeY').addEventListener('change', (e) => patch({ y: Number(e.target.value) }));
|
||||
document.getElementById('docShapeWidth').addEventListener('change', (e) => patch({ width: Number(e.target.value) }));
|
||||
document.getElementById('docShapeHeight').addEventListener('change', (e) => patch({ height: Number(e.target.value) }));
|
||||
document.getElementById('docShapeRotation').addEventListener('change', (e) => patch({ rotation: Number(e.target.value) }));
|
||||
document.getElementById('docShapeZ').addEventListener('change', (e) => patch({ z_index: Number(e.target.value) }));
|
||||
document.getElementById('docShapeFill').addEventListener('input', (e) => patch({ fill: e.target.value }));
|
||||
document.getElementById('docShapeStroke').addEventListener('input', (e) => patch({ stroke: e.target.value }));
|
||||
document.getElementById('docShapeStrokeWidth').addEventListener('change', (e) => patch({ stroke_width: Number(e.target.value) }));
|
||||
}
|
||||
|
||||
function forgeDocRenderImageProps(panel, el) {
|
||||
const a = el.attributes;
|
||||
panel.innerHTML = `
|
||||
@@ -2254,8 +2116,7 @@ function forgeDocRenderScenarioProps(panel, el) {
|
||||
* (retour utilisateur du 21/09/2026 : "passons à un véritable graphe
|
||||
* visuel", remplace la première version en liste de nœuds + menus
|
||||
* déroulants). Les nœuds sont des cartes positionnées/déplaçables à la
|
||||
* souris (glisser, comme les formes libres du canevas — voir
|
||||
* forgeDocBindShapeDrag) ; les choix qui ont une destination sont
|
||||
* souris (glisser) ; les choix qui ont une destination sont
|
||||
* dessinés comme des flèches SVG reliant les deux nœuds, étiquetées par
|
||||
* le texte du choix. Sélectionner un nœud (clic) ouvre son formulaire
|
||||
* d'édition (texte + choix) dans l'inspecteur (colonne de droite) —
|
||||
@@ -2268,7 +2129,7 @@ function forgeDocRenderScenarioProps(panel, el) {
|
||||
* pourrait diverger de ce que sanitize_scenario_config a réellement
|
||||
* accepté — SAUF la position pendant un glisser, mise à jour localement
|
||||
* en continu pour rester fluide, et persistée en un seul appel au
|
||||
* relâchement de la souris (même principe que forgeDocBindShapeDrag).
|
||||
* relâchement de la souris.
|
||||
* ------------------------------------------------------------------- */
|
||||
|
||||
let forgeDocScenarioGraphState = null;
|
||||
@@ -2737,7 +2598,6 @@ function forgeDocRenderProps(el) {
|
||||
return;
|
||||
}
|
||||
if (el.kind === 'titre' || el.kind === 'paragraphe') forgeDocRenderTextProps(panel, el);
|
||||
else if (forgeDocIsShapeKind(el.kind)) forgeDocRenderShapeProps(panel, el);
|
||||
else if (el.kind === 'image') forgeDocRenderImageProps(panel, el);
|
||||
else if (el.kind === 'bouton') forgeDocRenderButtonProps(panel, el);
|
||||
else if (el.kind === 'row') forgeDocRenderRowProps(panel, el);
|
||||
@@ -2869,37 +2729,23 @@ function forgeDocInit() {
|
||||
const root = document.getElementById('docEditor3');
|
||||
if (!root || !window.FORGE_DOCUMENT) return;
|
||||
|
||||
// Capturé AVANT window.forgeDocState (qui n'existe pas encore) : le
|
||||
// contenu initial de chaque vignette miniature vient du HTML déjà
|
||||
// rendu par le serveur (voir routes/document/document_edit.py::
|
||||
// rendered_pages, templates/document/document_edit.html), jamais
|
||||
// redemandé en AJAX au chargement — seul un futur forgeDocRenderPageTabs
|
||||
// (qui reconstruit #docPageTabs depuis ce cache) en a besoin.
|
||||
const initialPageThumbsHtml = {};
|
||||
document.querySelectorAll('.docPageThumbInner[data-page-id]').forEach((el) => {
|
||||
initialPageThumbsHtml[Number(el.dataset.pageId)] = el.innerHTML;
|
||||
});
|
||||
|
||||
window.forgeDocState = {
|
||||
slug: window.FORGE_DOCUMENT.slug,
|
||||
elements: window.FORGE_DOCUMENT.elements,
|
||||
elementsById: {},
|
||||
pages: window.FORGE_DOCUMENT.pages,
|
||||
activePageId: window.FORGE_DOCUMENT.activePageId,
|
||||
pageThumbsHtml: initialPageThumbsHtml,
|
||||
selectedId: null,
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
previewMode: false,
|
||||
zoom: 100,
|
||||
shapeDrag: null,
|
||||
};
|
||||
forgeDocRebuildIndex();
|
||||
|
||||
forgeDocBindCanvasInteractions();
|
||||
forgeDocBindLibraryDragHandlers();
|
||||
forgeDocBindCanvasDropZone();
|
||||
forgeDocBindShapeDragGlobalHandlers();
|
||||
forgeDocBindDeselectOnBackgroundClick();
|
||||
forgeDocBindZoom();
|
||||
forgeDocBindWidthPicker();
|
||||
@@ -2908,8 +2754,7 @@ function forgeDocInit() {
|
||||
forgeDocBindMobileNav();
|
||||
forgeDocBindKeyboardShortcuts();
|
||||
forgeDocBindScenarioTreeModal();
|
||||
forgeDocRenderPageTabs();
|
||||
forgeDocBindPageTabsDrag();
|
||||
forgeDocRenderPageList();
|
||||
forgeDocBindPageNavPill();
|
||||
forgeDocUpdateHistoryButtons();
|
||||
|
||||
|
||||
@@ -95,75 +95,70 @@
|
||||
<div class="docBodyWrap">
|
||||
<div class="docSidebarClose" id="docCloseLeft">← Retour au document</div>
|
||||
<div class="docSidebar docSidebarLeft" id="docSidebarLeft">
|
||||
<div class="docPageSection">
|
||||
<div class="docSectionLabel">Pages</div>
|
||||
<div class="docPageList" id="docPageList">
|
||||
{% for page in pages %}
|
||||
<div class="docPageListItem {{ 'is-active' if page.id == active_page.id else '' }}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-page-id="{{ page.id }}">
|
||||
{# //NOSONAR Web:S6819,Web:MouseEventWithoutKeyboardEquivalentCheck - div+role=button
|
||||
volontaire : la rangée contient de vrais <button> d'action (monter/descendre/
|
||||
renommer/supprimer), qu'un <button> englobant ne pourrait pas contenir
|
||||
validement. L'équivalent clavier (Entrée/Espace) est posé côté JS
|
||||
(forgeDocRenderPageList), donc le finding clavier de Sonar est un faux positif
|
||||
: l'analyseur statique ne voit pas les addEventListener dynamiques. #}
|
||||
<span class="docPageListTitle">{{ page.title }}</span>
|
||||
<div class="docPageListActions">
|
||||
<button type="button"
|
||||
class="docPageListMove"
|
||||
data-dir="up"
|
||||
data-page-id="{{ page.id }}"
|
||||
aria-label="Déplacer vers le haut"
|
||||
title="Déplacer vers le haut">↑</button>
|
||||
<button type="button"
|
||||
class="docPageListMove"
|
||||
data-dir="down"
|
||||
data-page-id="{{ page.id }}"
|
||||
aria-label="Déplacer vers le bas"
|
||||
title="Déplacer vers le bas">↓</button>
|
||||
<button type="button"
|
||||
class="docPageListRename"
|
||||
data-page-id="{{ page.id }}"
|
||||
aria-label="Renommer cette page"
|
||||
title="Renommer cette page">✎</button>
|
||||
<button type="button"
|
||||
class="docPageListDelete"
|
||||
data-page-id="{{ page.id }}"
|
||||
aria-label="Supprimer cette page"
|
||||
title="Supprimer cette page">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" class="docBtnSecondary docPageListAdd" id="docPageListAdd">+ Ajouter une page</button>
|
||||
</div>
|
||||
{% for category_key, category in element_library.items() %}
|
||||
<div>
|
||||
<div class="docSectionLabel">{{ category.label }}</div>
|
||||
{% if category_key == "mise_en_page" %}
|
||||
<div class="docShapesGrid">
|
||||
{% for kind in category.kinds %}
|
||||
<div class="docLibItem docLibItem--shape"
|
||||
draggable="true"
|
||||
data-add="{{ kind }}">
|
||||
<span class="docLibIcon docLibIcon--{{ kind }}"></span>
|
||||
<span>{{ element_kind_labels[kind] }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="docLibList">
|
||||
{% for kind in category.kinds %}
|
||||
<div class="docLibItem docLibItem--row"
|
||||
draggable="true"
|
||||
data-add="{{ kind }}">
|
||||
<span class="docLibIcon docLibIcon--{{ kind }}"></span>
|
||||
<span>{{ element_kind_labels[kind] }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="docLibList">
|
||||
{% for kind in category.kinds %}
|
||||
<div class="docLibItem docLibItem--row"
|
||||
draggable="true"
|
||||
data-add="{{ kind }}">
|
||||
<span class="docLibIcon docLibIcon--{{ kind }}"></span>
|
||||
<span>{{ element_kind_labels[kind] }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="docCanvasArea" id="docCanvasArea">
|
||||
<div class="docPageThumbs" id="docPageTabs">
|
||||
{% for page in pages %}
|
||||
<div class="docPageThumbCard {{ 'is-active' if page.id == active_page.id else '' }}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-page-id="{{ page.id }}"
|
||||
draggable="true">
|
||||
{# //NOSONAR Web:S6819,Web:MouseEventWithoutKeyboardEquivalentCheck - div+role=button
|
||||
est ICI le bon choix, pas une entorse à l'accessibilité : la vignette englobe le
|
||||
HTML réel de la page (rendered_pages[page.id]), qui peut contenir de vrais <button>
|
||||
(Suivant/Recommencer d'un mini-jeu) ; un <button> dans un <button> est invalide et
|
||||
le parseur HTML referme le bouton englobant trop tôt, corrompant l'arbre DOM (bug
|
||||
constaté en jsdom ET reproductible dans un vrai navigateur, même algorithme de
|
||||
parsing — voir CODE_QUALITY.md). L'équivalent clavier (Entrée/Espace) est posé côté
|
||||
JS sur ces cartes (forgeDocRenderPageTabs), donc le finding clavier est un faux
|
||||
positif : Sonar ne voit pas les addEventListener attachés dynamiquement. #}
|
||||
<span class="docPageThumbFrame">
|
||||
<span class="docPageThumbInner" data-page-id="{{ page.id }}" inert>{{ rendered_pages[page.id]|safe }}</span>
|
||||
{# //NOSONAR S5247 - rendered_pages[page.id] vient de document_engine.render_document, même garantie que rendered_document plus bas (échappement déjà fait côté Python) #}
|
||||
{# inert : les vrais <button>/liens éventuels du contenu de mini-jeu copié ici ne doivent jamais être atteignables au clavier (pure prévisualisation figée, pointer-events:none déjà côté CSS pour la souris) #}
|
||||
<span class="docPageThumbDelete"
|
||||
data-page-id="{{ page.id }}"
|
||||
title="Supprimer cette page">✕</span>
|
||||
</span>
|
||||
<span class="docPageThumbLabel">{{ page.title }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<button type="button"
|
||||
class="docPageThumbCard docPageThumbAddCard"
|
||||
id="docPageTabAdd"
|
||||
aria-label="Ajouter une page"
|
||||
title="Ajouter une page">
|
||||
<span class="docPageThumbFrame docPageThumbAddFrame">+</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="docPage" id="docPage">
|
||||
<div class="docPageContent" id="docPageContent">{{ rendered_document|safe }}</div>
|
||||
{# //NOSONAR S5247 - rendered_document vient de document_engine.render_document, qui échappe (html.escape) tout contenu utilisateur avant interpolation (voir document_engine/rendering/render_document_element.py) ; jamais de HTML brut non échappé ici #}
|
||||
<div class="docSnapGridOverlay" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="docZoomPill">
|
||||
<button type="button" id="docZoomOut" aria-label="Zoom -">−</button>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
— sans Flask, directement contre le support.db créé par db.create_support.
|
||||
Couvre : CRUD complet, rangées (parent_id), réinsertion (move_document_element
|
||||
vers une nouvelle rangée/position), suppression en cascade d'une rangée avec
|
||||
enfants, et les attributs de position/taille/rotation d'une forme.
|
||||
enfants.
|
||||
|
||||
Chaque élément appartient désormais à une page (voir document_engine/
|
||||
pages/) — _new_support renvoie (slug, page_id) de la première page créée
|
||||
@@ -73,31 +73,6 @@ def test_update_document_element_attributes_replaces_whole_json(tmp_support_slug
|
||||
assert element["attributes"] == {"content": "Texte modifié", "style": "legende"}
|
||||
|
||||
|
||||
def test_shape_default_attributes_carry_real_position_and_size(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "rectangle", page_id=page_id)
|
||||
element = doc_engine.get_document_element(slug, element_id)
|
||||
assert element is not None
|
||||
attrs = element["attributes"]
|
||||
assert attrs["x"] == 40
|
||||
assert attrs["y"] == 40
|
||||
assert attrs["width"] == 160
|
||||
assert attrs["height"] == 100
|
||||
assert attrs["rotation"] == 0
|
||||
assert attrs["z_index"] == 1
|
||||
|
||||
doc_engine.update_document_element_attributes(
|
||||
slug,
|
||||
element_id,
|
||||
{**attrs, "x": 120, "y": 80, "width": 200, "height": 150, "rotation": 45, "z_index": 3},
|
||||
)
|
||||
moved = doc_engine.get_document_element(slug, element_id)
|
||||
assert moved is not None
|
||||
assert moved["attributes"]["x"] == 120
|
||||
assert moved["attributes"]["rotation"] == 45
|
||||
assert moved["attributes"]["z_index"] == 3
|
||||
|
||||
|
||||
def test_move_document_element_reorders_within_same_parent(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
first_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
|
||||
@@ -141,16 +116,13 @@ def test_delete_document_element_on_missing_id_is_a_noop(tmp_support_slug_cleanu
|
||||
doc_engine.delete_document_element(slug, 999)
|
||||
|
||||
|
||||
def test_render_document_includes_row_children_and_shape_position(tmp_support_slug_cleanup: Any) -> None:
|
||||
def test_render_document_includes_row_children(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
row_id = doc_engine.add_document_element(slug, "row", page_id=page_id)
|
||||
doc_engine.add_document_element(slug, "titre", page_id=page_id, parent_id=row_id)
|
||||
rect_id = doc_engine.add_document_element(slug, "rectangle", page_id=page_id)
|
||||
titre_id = doc_engine.add_document_element(slug, "titre", page_id=page_id, parent_id=row_id)
|
||||
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
|
||||
assert 'data-kind="row"' in html
|
||||
assert 'data-kind="titre"' in html
|
||||
assert f'data-element-id="{rect_id}"' in html
|
||||
assert "left:40px" in html
|
||||
assert "top:40px" in html
|
||||
assert f'data-element-id="{titre_id}"' in html
|
||||
|
||||
Reference in New Issue
Block a user