Remplace les onglets texte de pages par de vraies vignettes miniatures
Build and deploy / test-python (push) Successful in 7m23s
Build and deploy / test-js (push) Successful in 1m24s
Build and deploy / lint-python (push) Successful in 6m29s
Build and deploy / lint-js (push) Failing after 1m31s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 5m18s

Les vignettes réutilisent le HTML réellement rendu de chaque page
(CSS scale trick) et sont centrées dans le conteneur du milieu, au
lieu d'une barre pleine largeur.

Corrige au passage deux bugs réels trouvés en écrivant les tests :
- une page contenant un mini-jeu (bouton Suivant/Recommencer) cassait
  le parsing HTML car .docPageThumbCard était un <button> englobant
  un autre <button> ; passage en div role="button" + équivalent
  clavier, contenu copié rendu inert.
- le renommage d'une page par double-clic ne fonctionnait plus du
  tout (sélecteur .docPageTab oublié lors du renommage des classes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-21 13:50:05 +02:00
co-authored by Claude Sonnet 5
parent ecd483f352
commit 57c4de3d8a
5 changed files with 273 additions and 125 deletions
+2 -1
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/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`) | `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`, ajouté le 20/09/2026) — 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 32 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 |
| 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 |
| `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,6 +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 |
### Détail — `javascript:S8786` (ReDoS), lot 1 "modernisation JS"
+25 -6
View File
@@ -1,3 +1,5 @@
from typing import Any
from flask import render_template
import db
@@ -21,6 +23,14 @@ def document_edit(slug: str) -> str:
(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.
Les attributs de chaque élément sont revalidés (document_engine.
sanitize_element_attributes) avant d'atteindre le client — jamais les
valeurs brutes stockées telles quelles : un élément dont le schéma a
@@ -31,17 +41,26 @@ def document_edit(slug: str) -> str:
support = db.support_meta(slug)
pages = document_engine.list_document_pages(slug)
active_page = pages[0]
elements = [
{**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])}
for el in document_engine.list_document_elements(slug, active_page["id"])
]
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
return render_template(
"document/document_edit.html",
support=support,
pages=pages,
active_page=active_page,
elements=elements,
rendered_document=document_engine.render_document(elements),
elements=active_elements,
rendered_document=rendered_pages[active_page["id"]],
rendered_pages=rendered_pages,
element_library=document_engine.ELEMENT_LIBRARY,
element_kind_labels=document_engine.ELEMENT_KIND_LABELS,
)
+126 -70
View File
@@ -366,7 +366,8 @@ body.objectEditBody > main.content {
padding: 36px 40px;
overflow: auto;
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
position: relative;
}
@@ -1212,102 +1213,157 @@ img.docImage {
white-space: nowrap;
}
/* ---- Bande d'onglets des PAGES (au-dessus du canevas, jamais un
panneau latéral — voir retour utilisateur du 21/09/2026). Masquée en
Mode Aperçu (voir la règle .docEditor3--preview plus bas), qui a sa
propre navigation via .docPageNavPill ci-dessus. ---- */
.docPageTabs {
/* ---- 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;
align-items: center;
gap: 4px;
padding: 8px 20px 0;
background: var(--doc-bg-2);
border-bottom: 1px solid var(--doc-border);
overflow-x: auto;
justify-content: center;
align-items: flex-end;
flex-wrap: wrap;
gap: 16px;
padding: 4px 20px 28px;
flex-shrink: 0;
}
.docPageTab {
.docPageThumbCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 7px 10px 7px 14px;
border: 1px solid var(--doc-border);
border-bottom: none;
border-radius: 8px 8px 0 0;
background: transparent;
color: var(--doc-muted);
font-size: 13px;
font-weight: 600;
gap: 6px;
flex-shrink: 0;
border: none;
background: none;
padding: 0;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
font-family: inherit;
}
.docPageTab:hover {
color: var(--doc-text);
border-color: var(--doc-accent);
}
.docPageTab.is-active {
background: var(--doc-bg);
color: var(--doc-text);
}
.docPageTab.is-dragging {
.docPageThumbCard.is-dragging {
opacity: 0.4;
}
.docPageTabTitle {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
.docPageThumbCard:focus-visible {
outline: 2px solid var(--doc-accent);
outline-offset: 2px;
border-radius: 10px;
}
.docPageTabTitle[contenteditable="true"] {
.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;
}
.docPageThumbDelete:hover {
background: var(--doc-quiz-danger-border);
}
.docPageThumbs.docPageTabs--single .docPageThumbDelete {
display: none;
}
.docPageThumbLabel {
max-width: 132px;
font-size: 12px;
font-weight: 600;
color: var(--doc-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.docPageThumbCard.is-active .docPageThumbLabel {
color: var(--doc-text);
}
.docPageThumbLabel[contenteditable="true"] {
outline: none;
border-bottom: 1px solid var(--doc-accent);
cursor: text;
user-select: text;
color: var(--doc-text);
}
.docPageTabDelete {
.docPageThumbAddCard .docPageThumbFrame {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
font-size: 10px;
border-style: dashed;
color: var(--doc-muted);
}
.docPageTabDelete:hover {
background: var(--doc-quiz-danger-bg);
color: var(--doc-quiz-danger-text);
}
.docPageTabs.docPageTabs--single .docPageTabDelete {
visibility: hidden;
}
.docPageTabAdd {
flex-shrink: 0;
width: 28px;
height: 28px;
margin-bottom: 6px;
border-radius: 8px;
border: 1px dashed var(--doc-border);
background: transparent;
color: var(--doc-muted);
font-size: 16px;
font-size: 24px;
font-weight: 700;
cursor: pointer;
box-shadow: none;
}
.docPageTabAdd:hover {
.docPageThumbAddCard:hover .docPageThumbFrame {
color: var(--doc-text);
border-color: var(--doc-accent);
}
/* ---- RIGHT SIDEBAR : Propriétés ---- */
@@ -1551,7 +1607,7 @@ img.docImage {
.docEditor3.docEditor3--preview .docSidebarRight,
.docEditor3.docEditor3--preview .docBottomNav,
.docEditor3.docEditor3--preview .docZoomPill,
.docEditor3.docEditor3--preview .docPageTabs {
.docEditor3.docEditor3--preview .docPageThumbs {
display: none;
}
+85 -36
View File
@@ -116,6 +116,15 @@ 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;
});
}
@@ -485,6 +494,11 @@ 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);
});
}
@@ -508,6 +522,7 @@ 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);
}
@@ -525,7 +540,7 @@ function forgeDocSelectAllText(el) {
}
function forgeDocStartRenamingPageTab(titleEl) {
const tab = titleEl.closest('.docPageTab');
const tab = titleEl.closest('.docPageThumbCard');
const pageId = Number(tab.dataset.pageId);
const original = titleEl.textContent;
titleEl.setAttribute('contenteditable', 'true');
@@ -568,30 +583,52 @@ function forgeDocRenderPageTabs() {
if (!container) return;
const pages = forgeDocOrderedPages();
container.classList.toggle('docPageTabs--single', pages.length <= 1);
const tabsHtml = pages.map((p) => `
<button type="button"
class="docPageTab ${p.id === window.forgeDocState.activePageId ? 'is-active' : ''}"
data-page-id="${p.id}"
draggable="true">
<span class="docPageTabTitle">${forgeDocEscapeHtml(p.title)}</span>
<span class="docPageTabDelete" data-page-id="${p.id}" title="Supprimer cette page">✕</span>
</button>
// 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' : ''}"
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>
</div>
`).join('');
container.innerHTML = `${tabsHtml}<button type="button" class="docPageTabAdd" id="docPageTabAdd" aria-label="Ajouter une page" title="Ajouter une page">+</button>`;
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('.docPageTab').forEach((tab) => {
tab.addEventListener('click', (e) => {
if (e.target.classList.contains('docPageTabDelete')) return;
forgeDocSwitchPage(Number(tab.dataset.pageId));
container.querySelectorAll('.docPageThumbCard[data-page-id]').forEach((card) => {
card.addEventListener('click', (e) => {
if (e.target.classList.contains('docPageThumbDelete')) return;
forgeDocSwitchPage(Number(card.dataset.pageId));
});
card.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
forgeDocSwitchPage(Number(card.dataset.pageId));
});
});
container.querySelectorAll('.docPageTabTitle').forEach((titleEl) => {
titleEl.addEventListener('dblclick', (e) => {
container.querySelectorAll('.docPageThumbLabel').forEach((labelEl) => {
labelEl.addEventListener('dblclick', (e) => {
e.stopPropagation();
forgeDocStartRenamingPageTab(titleEl);
forgeDocStartRenamingPageTab(labelEl);
});
});
container.querySelectorAll('.docPageTabDelete').forEach((btn) => {
container.querySelectorAll('.docPageThumbDelete').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
forgeDocDeletePage(Number(btn.dataset.pageId));
@@ -601,22 +638,22 @@ function forgeDocRenderPageTabs() {
}
function forgeDocBindPageTabsDrag() {
// Délégation sur le CONTENEUR (jamais les onglets eux-mêmes, recréés à
// chaque forgeDocRenderPageTabs) : un seul jeu de listeners pour toute
// la session, même principe que forgeDocMotsBindGlobalMouseup.
// 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 tab = e.target.closest('.docPageTab');
if (!tab) return;
draggedId = Number(tab.dataset.pageId);
tab.classList.add('is-dragging');
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 tab = e.target.closest('.docPageTab');
if (tab) tab.classList.remove('is-dragging');
const card = e.target.closest('.docPageThumbCard');
if (card) card.classList.remove('is-dragging');
});
container.addEventListener('dragover', (e) => {
if (draggedId !== null) e.preventDefault();
@@ -624,10 +661,10 @@ function forgeDocBindPageTabsDrag() {
container.addEventListener('drop', (e) => {
e.preventDefault();
if (draggedId === null) return;
const targetTab = e.target.closest('.docPageTab');
const targetCard = e.target.closest('.docPageThumbCard[data-page-id]');
const ordered = forgeDocOrderedPages();
const newIndex = targetTab
? ordered.findIndex((p) => p.id === Number(targetTab.dataset.pageId))
const newIndex = targetCard
? ordered.findIndex((p) => p.id === Number(targetCard.dataset.pageId))
: ordered.length - 1;
const movedId = draggedId;
draggedId = null;
@@ -845,7 +882,7 @@ function forgeDocQuizPlayerRestart(playerEl, state) {
}
function forgeDocInitQuizPlayers() {
document.querySelectorAll('.docQuizPlayer').forEach((playerEl) => {
document.querySelectorAll('#docPage .docQuizPlayer').forEach((playerEl) => {
const questions = forgeDocQuizPlayerQuestions(playerEl);
if (!questions.length) return;
const state = {
@@ -997,7 +1034,7 @@ function forgeDocAssociationRestart(playerEl) {
}
function forgeDocInitAssociationPlayers() {
document.querySelectorAll('.docAssocPlayer').forEach((playerEl) => {
document.querySelectorAll('#docPage .docAssocPlayer').forEach((playerEl) => {
const data = forgeDocAssociationPlayerData(playerEl);
if (!data.left.length) return;
forgeDocAssociationRestart(playerEl);
@@ -1162,7 +1199,7 @@ function forgeDocMemoryRestart(playerEl) {
}
function forgeDocInitMemoryPlayers() {
document.querySelectorAll('.docMemoryPlayer').forEach((playerEl) => {
document.querySelectorAll('#docPage .docMemoryPlayer').forEach((playerEl) => {
const data = forgeDocMemoryPlayerData(playerEl);
if (!data.cards.length) return;
forgeDocMemoryRestart(playerEl);
@@ -1301,7 +1338,7 @@ function forgeDocMotsBindGlobalMouseup() {
if (forgeDocMotsGlobalMouseupBound) return;
forgeDocMotsGlobalMouseupBound = true;
document.addEventListener('mouseup', () => {
document.querySelectorAll('.docMotsPlayer').forEach((playerEl) => {
document.querySelectorAll('#docPage .docMotsPlayer').forEach((playerEl) => {
const state = playerEl.forgeDocMotsState;
if (state && state.dragging) forgeDocMotsFinishSelection(playerEl, state);
});
@@ -1381,7 +1418,7 @@ function forgeDocMotsRestart(playerEl) {
function forgeDocInitMotsPlayers() {
forgeDocMotsBindGlobalMouseup();
document.querySelectorAll('.docMotsPlayer').forEach((playerEl) => {
document.querySelectorAll('#docPage .docMotsPlayer').forEach((playerEl) => {
const data = forgeDocMotsPlayerData(playerEl);
if (!data.words.length) return;
forgeDocMotsRestart(playerEl);
@@ -1494,7 +1531,7 @@ function forgeDocScenarioRestart(playerEl, state) {
}
function forgeDocInitScenarioPlayers() {
document.querySelectorAll('.docScenarioPlayer').forEach((playerEl) => {
document.querySelectorAll('#docPage .docScenarioPlayer').forEach((playerEl) => {
const scenarios = forgeDocScenarioPlayerData(playerEl);
if (!scenarios.length || !scenarios[0].nodes || !scenarios[0].nodes.length) return;
const state = { current: 0, scenarios, currentNodeId: scenarios[0].nodes[0].id };
@@ -2832,12 +2869,24 @@ 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: [],
+35 -12
View File
@@ -92,18 +92,6 @@
aria-label="Changer de thème"></button>
</div>
</div>
<div class="docPageTabs" id="docPageTabs">
{% for page in pages %}
<button type="button"
class="docPageTab {{ 'is-active' if page.id == active_page.id else '' }}"
data-page-id="{{ page.id }}"
draggable="true">
<span class="docPageTabTitle">{{ page.title }}</span>
<span class="docPageTabDelete" data-page-id="{{ page.id }}" title="Supprimer cette page">✕</span>
</button>
{% endfor %}
<button type="button" class="docPageTabAdd" id="docPageTabAdd" aria-label="Ajouter une page" title="Ajouter une page">+</button>
</div>
<div class="docBodyWrap">
<div class="docSidebarClose" id="docCloseLeft">← Retour au document</div>
<div class="docSidebar docSidebarLeft" id="docSidebarLeft">
@@ -137,6 +125,41 @@
{% 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 #}