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

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:
william
2026-09-21 14:40:33 +02:00
co-authored by Claude Sonnet 5
parent 57c4de3d8a
commit 8dc4b35dcf
11 changed files with 220 additions and 597 deletions
+2 -2
View File
@@ -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/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/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 | | `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/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/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 | | `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: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/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 | | `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" ### Détail — `javascript:S8786` (ReDoS), lot 1 "modernisation JS"
+5 -10
View File
@@ -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 document_engine/pages/, retour utilisateur du 21/09/2026 : "il faut
implémenter un système de page") ; chaque page structure son propre implémenter un système de page") ; chaque page structure son propre
contenu en deux couches sur le même canevas : contenu en deux couches sur le même canevas :
- le flux de contenu (titre/paragraphe/image/bouton/mini-jeux), organisé le flux de contenu (titre/paragraphe/image/bouton/mini-jeux), organisé en
en rangées ("row") par le moteur d'inférence de layout (voir rangées ("row") par le moteur d'inférence de layout (voir
document_engine/rendering/render_document_element.py) ; 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).
Tout est stocké dans support.db (voir db/supports/) : _document_pages (une Tout est stocké dans support.db (voir db/supports/) : _document_pages (une
ligne par page) et _document_elements (chaque élément appartient à ligne par page) et _document_elements (chaque élément appartient à
exactement une page via page_id, les deux couches ci-dessus portées via exactement une page via page_id, avec parent_id NULL au top-niveau ou
parent_id — NULL = top-niveau ou forme libre, sinon = enfant d'une enfant d'une rangée).
rangée).
Aucun import croisé avec game_engine ou tout module lié au jeu 2D — voir 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 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_KIND_LABELS,
ELEMENT_LIBRARY, ELEMENT_LIBRARY,
MINIGAME_KINDS, MINIGAME_KINDS,
SHAPE_KINDS,
element_default_attributes, element_default_attributes,
) )
from .labels.memory_config import ( from .labels.memory_config import (
@@ -103,7 +99,6 @@ __all__ = [
"MIN_TIMER_SECONDS", "MIN_TIMER_SECONDS",
"MIN_WORDS", "MIN_WORDS",
"MINIGAME_KINDS", "MINIGAME_KINDS",
"SHAPE_KINDS",
"add_document_element", "add_document_element",
"add_document_page", "add_document_page",
"delete_document_element", "delete_document_element",
@@ -11,7 +11,6 @@ from .mots_config import DEFAULT_MOTS_CONFIG
from .quiz_config import DEFAULT_QUIZ_CONFIG from .quiz_config import DEFAULT_QUIZ_CONFIG
from .scenario_config import DEFAULT_SCENARIO_CONFIG from .scenario_config import DEFAULT_SCENARIO_CONFIG
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton") CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton")
MINIGAME_KINDS = ("quiz", "association", "memory", "mots", "scenario", "zones") 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 # 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, présent dans ELEMENT_KIND_LABELS pour l'affichage/debug.
ELEMENT_LIBRARY: dict[str, dict[str, Any]] = { 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)}, "contenu": {"label": "Contenu", "kinds": list(CONTENT_KINDS)},
"minigames": {"label": "Mini-jeux", "kinds": list(MINIGAME_KINDS)}, "minigames": {"label": "Mini-jeux", "kinds": list(MINIGAME_KINDS)},
} }
ELEMENT_KIND_LABELS: dict[str, str] = { ELEMENT_KIND_LABELS: dict[str, str] = {
"row": "Rangée", "row": "Rangée",
"rectangle": "Rectangle",
"cercle": "Cercle",
"triangle": "Triangle",
"trait": "Trait",
"titre": "Titre", "titre": "Titre",
"paragraphe": "Paragraphe", "paragraphe": "Paragraphe",
"image": "Image", "image": "Image",
@@ -42,19 +36,6 @@ ELEMENT_KIND_LABELS: dict[str, str] = {
"zones": "Zones à risque", "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 = { _TEXT_DEFAULTS = {
"bold": False, "bold": False,
"italic": 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. """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 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.""" éditables par panneau, ici juste leur valeur de départ."""
if kind in SHAPE_KINDS:
return dict(_SHAPE_DEFAULTS)
if kind == "titre": if kind == "titre":
return {"content": "Nouveau titre", "style": "titre1", **_TEXT_DEFAULTS} return {"content": "Nouveau titre", "style": "titre1", **_TEXT_DEFAULTS}
if kind == "paragraphe": if kind == "paragraphe":
+3 -9
View File
@@ -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 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. 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, ...]` ## `CONTENT_KINDS: tuple[str, ...]`
`("titre", "paragraphe", "image", "bouton")` — éléments du flux, peuvent `("titre", "paragraphe", "image", "bouton")` — éléments du flux, peuvent
être top-niveau ou enfants d'une rangée. ê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]]` ## `ELEMENT_LIBRARY: dict[str, dict[str, Any]]`
Bibliothèque affichée dans le panneau gauche, groupée par catégorie Bibliothèque affichée dans le panneau gauche, groupée par catégorie
(`mise_en_page` / `contenu` / `minigames`), chaque entrée portant un (`contenu` / `minigames`), chaque entrée portant un `label` et sa liste
`label` et sa liste de `kinds`. `"row"` n'y apparaît jamais — créé de `kinds`. `"row"` n'y apparaît jamais — créé implicitement par le
implicitement par le moteur de layout, jamais choisi directement dans la moteur de layout, jamais choisi directement dans la bibliothèque.
bibliothèque.
## `ELEMENT_KIND_LABELS: dict[str, str]` ## `ELEMENT_KIND_LABELS: dict[str, str]`
Libellé d'affichage pour chaque `kind`, y compris `"row"` (pour 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 Attributs posés à la création d'un élément de ce type (voir
`document_engine/elements/add_document_element.py`). `document_engine/elements/add_document_element.py`).
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` : - **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`), texte (`content/style` + `bold/italic/underline/align/color`),
image (`src/alt`), bouton (`label/target`), rangée (`gap/align/justify`), image (`src/alt`), bouton (`label/target`), rangée (`gap/align/justify`),
quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), association quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), association
@@ -3,8 +3,6 @@ import json
import random import random
from typing import Any from typing import Any
_SHAPE_TAGS = {"rectangle": "rect", "cercle": "circle", "trait": "line"}
def render_document(elements: list[dict[str, Any]]) -> str: def render_document(elements: list[dict[str, Any]]) -> str:
"""Assemble le document ENTIER à partir de la liste à plat renvoyée """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>' 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 = { _STYLE_PRESETS = {
"titre1": ("clamp(1.6rem,4vw,2rem)", 800, 1.15), "titre1": ("clamp(1.6rem,4vw,2rem)", 800, 1.15),
"titre2": ("1.3rem", 800, 1.25), "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 = { _RENDERERS = {
"row": _render_row, "row": _render_row,
"rectangle": _render_shape,
"cercle": _render_shape,
"triangle": _render_shape,
"trait": _render_shape,
"titre": _render_text, "titre": _render_text,
"paragraphe": _render_text, "paragraphe": _render_text,
"image": _render_image, "image": _render_image,
-3
View File
@@ -28,9 +28,6 @@ regroupement à chaque appel.
- **Rangée** (`row`) : conteneur flex (`gap`/`align-items`/ - **Rangée** (`row`) : conteneur flex (`gap`/`align-items`/
`justify-content` réels depuis `attributes`), enfants rendus `justify-content` réels depuis `attributes`), enfants rendus
récursivement. 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 - **Texte** (`titre`/`paragraphe`) : `<div>` stylé selon `style` (préréglage
taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`. taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`.
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide. - **Image** : `<img>`, ou un bloc placeholder si `src` est vide.
+11 -21
View File
@@ -21,15 +21,12 @@ def document_edit(slug: str) -> str:
PREMIÈRE page (triée par order_index) — changer de page se fait 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= ensuite entièrement côté client via /document/<slug>/render?page_id=
(voir static/document/js/document-editor.js), jamais un rechargement (voir static/document/js/document-editor.js), jamais un rechargement
complet de cette route. complet de cette route. La navigation entre pages (ajout/renommage/
suppression/réordonnancement) vit dans une section dédiée du panneau
`rendered_pages` (retour utilisateur du 21/09/2026 : "je préfère gauche (retour utilisateur du 21/09/2026 : "une section qui s'ajoute
avoir des miniatures") rend le document de CHAQUE page — pas dans le panneau de gauche pour ajouter une page et naviguer entre
seulement la page active — pour construire les vraies vignettes elles"), qui n'a besoin que des métadonnées de page (`pages`), jamais
miniatures de la bande au-dessus du canevas (voir templates/document/ d'un rendu de leur contenu.
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.
Les attributs de chaque élément sont revalidés (document_engine. Les attributs de chaque élément sont revalidés (document_engine.
sanitize_element_attributes) avant d'atteindre le client — jamais les 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) pages = document_engine.list_document_pages(slug)
active_page = pages[0] active_page = pages[0]
rendered_pages: dict[int, str] = {} active_elements: list[dict[str, Any]] = [
active_elements: list[dict[str, Any]] = [] {**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])}
for page in pages: for el in document_engine.list_document_elements(slug, active_page["id"])
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
return render_template( return render_template(
"document/document_edit.html", "document/document_edit.html",
@@ -59,8 +50,7 @@ def document_edit(slug: str) -> str:
pages=pages, pages=pages,
active_page=active_page, active_page=active_page,
elements=active_elements, elements=active_elements,
rendered_document=rendered_pages[active_page["id"]], rendered_document=document_engine.render_document(active_elements),
rendered_pages=rendered_pages,
element_library=document_engine.ELEMENT_LIBRARY, element_library=document_engine.ELEMENT_LIBRARY,
element_kind_labels=document_engine.ELEMENT_KIND_LABELS, element_kind_labels=document_engine.ELEMENT_KIND_LABELS,
) )
+67 -171
View File
@@ -260,12 +260,6 @@ body.objectEditBody > main.content {
margin-bottom: 10px; margin-bottom: 10px;
} }
.docShapesGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.docLibItem { .docLibItem {
border: 1px solid var(--doc-border); border: 1px solid var(--doc-border);
border-radius: 10px; border-radius: 10px;
@@ -279,14 +273,6 @@ body.objectEditBody > main.content {
box-shadow: var(--doc-glow); box-shadow: var(--doc-glow);
} }
.docLibItem--shape {
padding: 10px 8px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.docLibItem--row { .docLibItem--row {
padding: 9px 10px; padding: 9px 10px;
display: flex; display: flex;
@@ -315,10 +301,6 @@ body.objectEditBody > main.content {
flex-shrink: 0; flex-shrink: 0;
} }
.docLibIcon--rectangle::before { content: "▭"; }
.docLibIcon--cercle::before { content: "○"; }
.docLibIcon--triangle::before { content: "△"; }
.docLibIcon--trait::before { content: "―"; }
.docLibIcon--paragraphe::before { content: "¶"; } .docLibIcon--paragraphe::before { content: "¶"; }
.docLibIcon--image::before { content: "🖼"; } .docLibIcon--image::before { content: "🖼"; }
.docLibIcon--bouton::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 /* Enfant qui reçoit le HTML rendu (voir #docPage vs #docPageContent dans
document-editor.js — le rafraîchissement du canevas ne remplace QUE ce document-editor.js — le rafraîchissement du canevas ne remplace QUE ce
contenu, jamais .docPage elle-même, pour que .docSnapGridOverlay, contenu, jamais .docPage elle-même). */
sa voisine, survive à chaque rafraîchissement). */
.docPageContent { .docPageContent {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -447,17 +428,6 @@ body.objectEditBody > main.content {
box-shadow: inset -3px 0 0 0 var(--doc-accent); 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 { .docText {
outline: none; outline: none;
} }
@@ -1096,27 +1066,6 @@ img.docImage {
padding: 10px; 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 { .docZoomPill {
position: absolute; position: absolute;
bottom: 24px; bottom: 24px;
@@ -1213,125 +1162,53 @@ img.docImage {
white-space: nowrap; white-space: nowrap;
} }
/* ---- Bande de VIGNETTES MINIATURES des pages, au-dessus de la page /* ---- Section "Pages" du panneau GAUCHE (retour utilisateur du
ET centrée dans le conteneur du milieu (.docCanvasArea, jamais toute 21/09/2026 : "une section qui s'ajoute dans le panneau de gauche pour
la largeur de l'éditeur par-dessus les panneaux latéraux — retour ajouter une page et naviguer entre elles", à la place de l'ex-
utilisateur du 21/09/2026 : "je préfère avoir des miniatures ... catégorie "Mise en page"/formes libres, supprimée) — une simple liste
centré dans le conteneur du milieu"). Chaque vignette affiche un (pas de vignette visuelle), toujours visible en tête du panneau. ---- */
VRAI rendu réduit du contenu de sa page (voir .docPageThumbInner plus .docPageSection {
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 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; gap: 8px;
gap: 6px;
flex-shrink: 0;
border: none;
background: none;
padding: 0;
cursor: pointer;
font-family: inherit;
} }
.docPageThumbCard.is-dragging { .docPageList {
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 {
display: flex; display: flex;
flex-direction: column;
gap: 4px;
} }
.docPageThumbDelete:hover { .docPageListItem {
background: var(--doc-quiz-danger-border); 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 { .docPageListItem:hover {
display: none; border-color: var(--doc-accent);
} }
.docPageThumbLabel { .docPageListItem.is-active {
max-width: 132px; border-color: var(--doc-accent);
font-size: 12px; 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; font-weight: 600;
color: var(--doc-muted); color: var(--doc-muted);
overflow: hidden; overflow: hidden;
@@ -1339,11 +1216,11 @@ img.docImage {
white-space: nowrap; white-space: nowrap;
} }
.docPageThumbCard.is-active .docPageThumbLabel { .docPageListItem.is-active .docPageListTitle {
color: var(--doc-text); color: var(--doc-text);
} }
.docPageThumbLabel[contenteditable="true"] { .docPageListTitle[contenteditable="true"] {
outline: none; outline: none;
border-bottom: 1px solid var(--doc-accent); border-bottom: 1px solid var(--doc-accent);
cursor: text; cursor: text;
@@ -1351,21 +1228,41 @@ img.docImage {
color: var(--doc-text); 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; display: flex;
align-items: center; align-items: center;
justify-content: 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); 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 ---- */ /* ---- RIGHT SIDEBAR : Propriétés ---- */
.docField { .docField {
display: flex; display: flex;
@@ -1606,8 +1503,7 @@ img.docImage {
.docEditor3.docEditor3--preview .docSidebarLeft, .docEditor3.docEditor3--preview .docSidebarLeft,
.docEditor3.docEditor3--preview .docSidebarRight, .docEditor3.docEditor3--preview .docSidebarRight,
.docEditor3.docEditor3--preview .docBottomNav, .docEditor3.docEditor3--preview .docBottomNav,
.docEditor3.docEditor3--preview .docZoomPill, .docEditor3.docEditor3--preview .docZoomPill {
.docEditor3.docEditor3--preview .docPageThumbs {
display: none; display: none;
} }
+74 -229
View File
@@ -19,14 +19,6 @@ var FORGE_DOC_STYLE_PRESETS = {
legende: 'Légende', 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) { function forgeDocChildrenOf(parentId) {
return window.forgeDocState.elements return window.forgeDocState.elements
.filter((el) => el.parent_id === parentId) .filter((el) => el.parent_id === parentId)
@@ -116,15 +108,6 @@ function forgeDocRefreshCanvas() {
window.forgeDocState.selectedId = null; window.forgeDocState.selectedId = null;
forgeDocRenderProps(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) { function forgeDocFindDropTarget(clientX, clientY) {
const candidates = Array.from(document.querySelectorAll('#docPage [data-element-id]')) const candidates = Array.from(document.querySelectorAll('#docPage [data-element-id]'));
.filter((dom) => !forgeDocIsShapeKind(dom.dataset.kind));
let best = null; let best = null;
let bestDist = Infinity; let bestDist = Infinity;
candidates.forEach((dom) => { candidates.forEach((dom) => {
@@ -421,10 +403,13 @@ function forgeDocBindCanvasDropZone() {
/* --------------------------------------------------------------------- /* ---------------------------------------------------------------------
* Pages — un support est composé de plusieurs pages (retour utilisateur * 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 * 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 * document indépendant affiché SEUL sur le canevas à la fois. Section
* d'ONGLETS au-dessus du canevas pour créer/renommer (double-clic)/ * dédiée dans le panneau GAUCHE (retour utilisateur du 21/09/2026 :
* réordonner (glisser)/supprimer (voir templates/document/ * "une section qui s'ajoute dans le panneau de gauche pour ajouter une
* document_edit.html) — jamais un panneau latéral (retour utilisateur). * 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) * Changer de page VIDE la pile Annuler/Rétablir (voir forgeDocSwitchPage)
* : une commande empilée sur une autre page rejouerait un appel AJAX * : 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. * ciblant des éléments qui ne sont plus affichés, aucun sens à la garder.
@@ -484,7 +469,7 @@ function forgeDocSwitchPage(pageId) {
forgeDocUpdateHistoryButtons(); forgeDocUpdateHistoryButtons();
window.forgeDocState.selectedId = null; window.forgeDocState.selectedId = null;
forgeDocRenderProps(null); forgeDocRenderProps(null);
forgeDocRenderPageTabs(); forgeDocRenderPageList();
forgeDocUpdatePageNavPill(); forgeDocUpdatePageNavPill();
return forgeDocRefreshCanvas(); return forgeDocRefreshCanvas();
} }
@@ -494,11 +479,6 @@ function forgeDocAddPage() {
window.forgeDocState.pages.push({ window.forgeDocState.pages.push({
id: result.id, title: result.title, order_index: window.forgeDocState.pages.length, 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); return forgeDocSwitchPage(result.id);
}); });
} }
@@ -507,7 +487,7 @@ function forgeDocRenamePage(pageId, title) {
return forgeDocApiPageRename(pageId, title).then((result) => { return forgeDocApiPageRename(pageId, title).then((result) => {
const page = window.forgeDocState.pages.find((p) => p.id === pageId); const page = window.forgeDocState.pages.find((p) => p.id === pageId);
if (page) page.title = result.title; if (page) page.title = result.title;
forgeDocRenderPageTabs(); forgeDocRenderPageList();
}); });
} }
@@ -522,15 +502,31 @@ function forgeDocDeletePage(pageId) {
return Promise.resolve(); return Promise.resolve();
} }
window.forgeDocState.pages = window.forgeDocState.pages.filter((p) => p.id !== pageId); window.forgeDocState.pages = window.forgeDocState.pages.filter((p) => p.id !== pageId);
delete window.forgeDocState.pageThumbsHtml[pageId];
if (window.forgeDocState.activePageId === pageId) { if (window.forgeDocState.activePageId === pageId) {
return forgeDocSwitchPage(forgeDocOrderedPages()[0].id); return forgeDocSwitchPage(forgeDocOrderedPages()[0].id);
} }
forgeDocRenderPageTabs(); forgeDocRenderPageList();
return Promise.resolve(); 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) { function forgeDocSelectAllText(el) {
const range = document.createRange(); const range = document.createRange();
range.selectNodeContents(el); range.selectNodeContents(el);
@@ -539,9 +535,10 @@ function forgeDocSelectAllText(el) {
selection.addRange(range); selection.addRange(range);
} }
function forgeDocStartRenamingPageTab(titleEl) { function forgeDocStartRenamingPage(pageId) {
const tab = titleEl.closest('.docPageThumbCard'); const item = document.querySelector(`.docPageListItem[data-page-id="${pageId}"]`);
const pageId = Number(tab.dataset.pageId); if (!item) return;
const titleEl = item.querySelector('.docPageListTitle');
const original = titleEl.textContent; const original = titleEl.textContent;
titleEl.setAttribute('contenteditable', 'true'); titleEl.setAttribute('contenteditable', 'true');
titleEl.focus(); titleEl.focus();
@@ -578,107 +575,60 @@ function forgeDocStartRenamingPageTab(titleEl) {
titleEl.addEventListener('keydown', onKeydown); titleEl.addEventListener('keydown', onKeydown);
} }
function forgeDocRenderPageTabs() { function forgeDocRenderPageList() {
const container = document.getElementById('docPageTabs'); const container = document.getElementById('docPageList');
if (!container) return; if (!container) return;
const pages = forgeDocOrderedPages(); const pages = forgeDocOrderedPages();
container.classList.toggle('docPageTabs--single', pages.length <= 1); // div + role="button" ici, jamais <button> : la rangée contient de
// Le contenu de chaque vignette (.docPageThumbInner) vient du CACHE // vrais <button> d'action (monter/descendre/renommer/supprimer), qu'un
// client (window.forgeDocState.pageThumbsHtml, alimenté au chargement // <button> englobant ne pourrait pas contenir validement. L'équivalent
// et tenu à jour par forgeDocRefreshCanvas) — jamais ré-échappé ici : // clavier (Entrée/Espace) est posé plus bas.
// c'est déjà du HTML de confiance produit par document_engine. container.innerHTML = pages.map((p, index) => `
// render_document côté serveur (même contrat que data.html pour le <div class="docPageListItem ${p.id === window.forgeDocState.activePageId ? 'is-active' : ''}"
// 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' : ''}"
role="button" role="button"
tabindex="0" tabindex="0"
data-page-id="${p.id}" data-page-id="${p.id}">
draggable="true"> <span class="docPageListTitle">${forgeDocEscapeHtml(p.title)}</span>
<span class="docPageThumbFrame"> <div class="docPageListActions">
<span class="docPageThumbInner" data-page-id="${p.id}" inert>${window.forgeDocState.pageThumbsHtml[p.id] || ''}</span> <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>
<span class="docPageThumbDelete" data-page-id="${p.id}" title="Supprimer cette page">✕</span> <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>
</span> <button type="button" class="docPageListRename" data-page-id="${p.id}" aria-label="Renommer cette page" title="Renommer cette page">✎</button>
<span class="docPageThumbLabel">${forgeDocEscapeHtml(p.title)}</span> <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> </div>
`).join(''); `).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) => { container.querySelectorAll('.docPageListItem[data-page-id]').forEach((item) => {
card.addEventListener('click', (e) => { item.addEventListener('click', (e) => {
if (e.target.classList.contains('docPageThumbDelete')) return; if (e.target.closest('.docPageListActions')) return;
forgeDocSwitchPage(Number(card.dataset.pageId)); forgeDocSwitchPage(Number(item.dataset.pageId));
}); });
card.addEventListener('keydown', (e) => { item.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return; if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault(); e.preventDefault();
forgeDocSwitchPage(Number(card.dataset.pageId)); forgeDocSwitchPage(Number(item.dataset.pageId));
}); });
}); });
container.querySelectorAll('.docPageThumbLabel').forEach((labelEl) => { container.querySelectorAll('.docPageListMove').forEach((btn) => {
labelEl.addEventListener('dblclick', (e) => { btn.addEventListener('click', (e) => {
e.stopPropagation(); 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) => { btn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
forgeDocDeletePage(Number(btn.dataset.pageId)); forgeDocDeletePage(Number(btn.dataset.pageId));
}); });
}); });
document.getElementById('docPageTabAdd').addEventListener('click', () => forgeDocAddPage()); const addBtn = document.getElementById('docPageListAdd');
} if (addBtn) addBtn.onclick = () => 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();
});
});
} }
function forgeDocBindPageNavPill() { 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. * Sélection & panneau Propriétés.
* ------------------------------------------------------------------- */ * ------------------------------------------------------------------- */
@@ -758,15 +655,11 @@ function forgeDocBindCanvasInteractions() {
e.stopPropagation(); e.stopPropagation();
forgeDocSelectElement(elementId); forgeDocSelectElement(elementId);
}); });
if (forgeDocIsShapeKind(dom.dataset.kind)) { dom.setAttribute('draggable', 'true');
forgeDocBindShapeDrag(dom, elementId); dom.addEventListener('dragstart', (e) => {
} else { e.dataTransfer.setData('text/forge-doc-move', String(elementId));
dom.setAttribute('draggable', 'true'); e.dataTransfer.effectAllowed = 'move';
dom.addEventListener('dragstart', (e) => { });
e.dataTransfer.setData('text/forge-doc-move', String(elementId));
e.dataTransfer.effectAllowed = 'move';
});
}
}); });
forgeDocInitQuizPlayers(); forgeDocInitQuizPlayers();
forgeDocInitAssociationPlayers(); 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) { function forgeDocRenderImageProps(panel, el) {
const a = el.attributes; const a = el.attributes;
panel.innerHTML = ` panel.innerHTML = `
@@ -2254,8 +2116,7 @@ function forgeDocRenderScenarioProps(panel, el) {
* (retour utilisateur du 21/09/2026 : "passons à un véritable graphe * (retour utilisateur du 21/09/2026 : "passons à un véritable graphe
* visuel", remplace la première version en liste de nœuds + menus * 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 * déroulants). Les nœuds sont des cartes positionnées/déplaçables à la
* souris (glisser, comme les formes libres du canevas — voir * souris (glisser) ; les choix qui ont une destination sont
* forgeDocBindShapeDrag) ; les choix qui ont une destination sont
* dessinés comme des flèches SVG reliant les deux nœuds, étiquetées par * 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 * le texte du choix. Sélectionner un nœud (clic) ouvre son formulaire
* d'édition (texte + choix) dans l'inspecteur (colonne de droite) — * 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 * pourrait diverger de ce que sanitize_scenario_config a réellement
* accepté — SAUF la position pendant un glisser, mise à jour localement * accepté — SAUF la position pendant un glisser, mise à jour localement
* en continu pour rester fluide, et persistée en un seul appel au * 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; let forgeDocScenarioGraphState = null;
@@ -2737,7 +2598,6 @@ function forgeDocRenderProps(el) {
return; return;
} }
if (el.kind === 'titre' || el.kind === 'paragraphe') forgeDocRenderTextProps(panel, el); 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 === 'image') forgeDocRenderImageProps(panel, el);
else if (el.kind === 'bouton') forgeDocRenderButtonProps(panel, el); else if (el.kind === 'bouton') forgeDocRenderButtonProps(panel, el);
else if (el.kind === 'row') forgeDocRenderRowProps(panel, el); else if (el.kind === 'row') forgeDocRenderRowProps(panel, el);
@@ -2869,37 +2729,23 @@ function forgeDocInit() {
const root = document.getElementById('docEditor3'); const root = document.getElementById('docEditor3');
if (!root || !window.FORGE_DOCUMENT) return; 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 = { window.forgeDocState = {
slug: window.FORGE_DOCUMENT.slug, slug: window.FORGE_DOCUMENT.slug,
elements: window.FORGE_DOCUMENT.elements, elements: window.FORGE_DOCUMENT.elements,
elementsById: {}, elementsById: {},
pages: window.FORGE_DOCUMENT.pages, pages: window.FORGE_DOCUMENT.pages,
activePageId: window.FORGE_DOCUMENT.activePageId, activePageId: window.FORGE_DOCUMENT.activePageId,
pageThumbsHtml: initialPageThumbsHtml,
selectedId: null, selectedId: null,
undoStack: [], undoStack: [],
redoStack: [], redoStack: [],
previewMode: false, previewMode: false,
zoom: 100, zoom: 100,
shapeDrag: null,
}; };
forgeDocRebuildIndex(); forgeDocRebuildIndex();
forgeDocBindCanvasInteractions(); forgeDocBindCanvasInteractions();
forgeDocBindLibraryDragHandlers(); forgeDocBindLibraryDragHandlers();
forgeDocBindCanvasDropZone(); forgeDocBindCanvasDropZone();
forgeDocBindShapeDragGlobalHandlers();
forgeDocBindDeselectOnBackgroundClick(); forgeDocBindDeselectOnBackgroundClick();
forgeDocBindZoom(); forgeDocBindZoom();
forgeDocBindWidthPicker(); forgeDocBindWidthPicker();
@@ -2908,8 +2754,7 @@ function forgeDocInit() {
forgeDocBindMobileNav(); forgeDocBindMobileNav();
forgeDocBindKeyboardShortcuts(); forgeDocBindKeyboardShortcuts();
forgeDocBindScenarioTreeModal(); forgeDocBindScenarioTreeModal();
forgeDocRenderPageTabs(); forgeDocRenderPageList();
forgeDocBindPageTabsDrag();
forgeDocBindPageNavPill(); forgeDocBindPageNavPill();
forgeDocUpdateHistoryButtons(); forgeDocUpdateHistoryButtons();
+54 -59
View File
@@ -95,75 +95,70 @@
<div class="docBodyWrap"> <div class="docBodyWrap">
<div class="docSidebarClose" id="docCloseLeft">← Retour au document</div> <div class="docSidebarClose" id="docCloseLeft">← Retour au document</div>
<div class="docSidebar docSidebarLeft" id="docSidebarLeft"> <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() %} {% for category_key, category in element_library.items() %}
<div> <div>
<div class="docSectionLabel">{{ category.label }}</div> <div class="docSectionLabel">{{ category.label }}</div>
{% if category_key == "mise_en_page" %} <div class="docLibList">
<div class="docShapesGrid"> {% for kind in category.kinds %}
{% for kind in category.kinds %} <div class="docLibItem docLibItem--row"
<div class="docLibItem docLibItem--shape" draggable="true"
draggable="true" data-add="{{ kind }}">
data-add="{{ kind }}"> <span class="docLibIcon docLibIcon--{{ kind }}"></span>
<span class="docLibIcon docLibIcon--{{ kind }}"></span> <span>{{ element_kind_labels[kind] }}</span>
<span>{{ element_kind_labels[kind] }}</span> </div>
</div> {% endfor %}
{% endfor %} </div>
</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> </div>
{% endfor %} {% endfor %}
</div> </div>
<div class="docCanvasArea" id="docCanvasArea"> <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="docPage" id="docPage">
<div class="docPageContent" id="docPageContent">{{ rendered_document|safe }}</div> <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 #} {# //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>
<div class="docZoomPill"> <div class="docZoomPill">
<button type="button" id="docZoomOut" aria-label="Zoom -">−</button> <button type="button" id="docZoomOut" aria-label="Zoom -">−</button>
+4 -32
View File
@@ -2,7 +2,7 @@
— sans Flask, directement contre le support.db créé par db.create_support. — sans Flask, directement contre le support.db créé par db.create_support.
Couvre : CRUD complet, rangées (parent_id), réinsertion (move_document_element 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 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/ 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 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"} 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: def test_move_document_element_reorders_within_same_parent(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup) slug, page_id = _new_support(tmp_support_slug_cleanup)
first_id = doc_engine.add_document_element(slug, "titre", page_id=page_id) 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) 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) slug, page_id = _new_support(tmp_support_slug_cleanup)
row_id = doc_engine.add_document_element(slug, "row", page_id=page_id) 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) titre_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)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id)) html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert 'data-kind="row"' in html assert 'data-kind="row"' in html
assert 'data-kind="titre"' in html assert 'data-kind="titre"' in html
assert f'data-element-id="{rect_id}"' in html assert f'data-element-id="{titre_id}"' in html
assert "left:40px" in html
assert "top:40px" in html